iOS單元測試之UI測試

一、UI測試簡介

1.1、什么是UITesting

2015 年,Apple 發布了 UI 自動化測試框架 XCUITest 并集成在 Xcode7 中,而 iOS/macOS UI 自動化測試依賴兩個核心技術:XCUITest 和 Accessibility。

XCUITest 和 Accessibility.png

XCUITest 是集成在 Xcode 中的測試框架,若想使用 UI 測試功能,可以在創建 iOS 項目時勾選 Include Tests 選項,從而使項目具備自動化測試的能力。而 Accessibility 技術,則是 Apple 官方為視障用戶提供的一整套使用 iOS/macOS App 的解決方案。

Xcode 項目創建 UITests Target 并運行測試,其編譯產物 Test App 本質上是一個 Deamon 守護進程,該進程有獨立的應用程序生命周期,依靠 XCUIApplication 類型進行管理。UITests 的 Test App 進程在運行時會驅動 Host App(項目的主 Target 產物),并且利用元素審查的相關 API 驅動 Host App 模擬用戶行為交互,從而進行 UI 自動化測試。

對于 Accessibility 技術,開發人員需要注意的是,XCUITest 框架默認并不能將所有視圖元素審查到,只會審查到可以被 VoiceOver 功能讀取文字的元素。比如,UIButton 和 UILabel,這些視圖對于視障用戶而言可以通過語音來獲知其內容,而對于 UIImageView、 UIView 這種對于視障人士并不友好的 UIKit 視圖元素默認是不會審查到的,所以編碼時要另行配置 Accessibility 相關屬性,以保證其支持 Accessibility 從而在 UI 自動化查詢的元素層級中可見。

基于 XCUITest 框架 和 Accessibility 技術的自動化測試,有利于 App 進行數據一致性校驗,但 UI 一致性校驗能力較弱。比如,App 可以針對某些數據請求結果或者某個元素是否存在進行校驗,而視覺展示效果卻仍需要人工介入。

1.2、使用UITesting

Using UI Testing:

  • Complements unit testing(補充單元測試)
  • Unit testing more precisely pinpoints failures(單元測試更精確地確定了失敗)
  • UI testing covers broader aspects of functionality(UI測試覆蓋了函數邊界方面)
  • Find the right blend of UI tests and unit tests for your project(找到好的方式融合UI測試和單元測試)

Candidates for UI Testing(使用UI測試的情況):

  • Demo sequences(一些列Demo)
  • Common workflows(相同的工作流程)
  • Custom views(相同的視圖)
  • Document creation, saving, and opening(文檔的創建、保存、打開)

1.3、UI Recording

通過 UI Recording ,可以將你操作手機的行為記錄下來,并且轉換成代碼,可以幫助你快速生成 UI 測試代碼。選中 UI 測試類,你能再下方看到一個小紅點,點擊小紅點開始錄制你的交互。

UIRecording.png

在你進行交互時,Xcode 會自動轉化成代碼,你可以借此創建新的測試代碼,也可以以此拓展已經存在的測試代碼。當然它也不是十分完美,并不是總能如你所愿,還需要你做一些處理,比如說自動生成的代碼過于繁瑣,你可以用一些更簡潔的代碼實現。即使這樣,UI Recording 也是非常高效的方式。點擊下載Demo:ZJHUnitTestDemo

UIRecording.gif

二、UI 測試相關的類

2.1、XCUITest 框架結構

XCUITest 框架結構圖.png

XCUITest 測試框架 API 主要包含:元素查詢(UI Element Queries)相關類型,如 XCUIElementQuery,UI 元素(UI Elements)相關類型,如 XCUIElement,以及測試 App 生命周期類型(Application Lifecycle)類型,如 XCUIApplication。

2.2、XCUIApplication

XCUIApplication 代表整個應用,可以用來啟動、結束進程,或者傳入一些啟動參數,最常用的功能是利用 XCUIApplication 實例來查詢 UI 上的元素。

// 返回 UI 測試 Target 設置中選中的 Target Application 的實例
- (instancetype)init;

// 根據 bundleId 返回一個應用程序實例
- (instancetype)initWithBundleIdentifier:(NSString *)bundleIdentifier;

// 啟動應用程序
- (void)launch;

// 將應用程序喚醒至前臺,在多程序聯合測試下會用到 
- (void)activate;

// 結束一個正在運行的應用程序
- (void)terminate;

2.3、XCUIElement

XCUIElement 應用程序中的 UI 控件,控件類型多樣,可能是Button,Cell,Window等等。該類實例有很多模擬交互的方法,如tap模擬用戶點擊事件,swipe模擬滑動事件,typeText:模擬用戶輸入內容。在 UI 測試中我們需要找到某個空間,可以通過他們的類型來縮小范圍。另外還有一種方式通過 Accessibility identifer, label, title 等等方式來定位對應的控件。通過類型加 identifier 的方式來定位的控件元素的方式,可以滿足大多數場景。

XCUIElement示例.png

也可以通過代碼的方式添加Accessibility identifer。

// 通過代碼的方式添加Accessibility identifer
for (int i = 0; i < 5; i++) {
        UISwitch *swt = [UISwitch new];
        CGFloat pointY = 50 * i + 100;
        swt.center = CGPointMake(self.view.frame.size.width/2, pointY);
        // 添加accessibility標記
        swt.accessibilityLabel = [NSString stringWithFormat:@"swt-%d", I];
        [self.view addSubview:swt];
}
  
  
 // UI測試獲取控件
 XCUIApplication *app = [[XCUIApplication alloc] init];
 [app launch];
 XCUIElement *codeSwt1 = app.switches[@"swt-1"];
 [codeSwt1 tap];

2.4、XCUIElementQuery

XCUIElementQuery 是一個用來定位控件元素的類,一般是一組符合篩選條件的元素集合。如app.buttons即返回 XCUIElementQuery 實例,是包含了當前所有的button的集合,你可以再通過 XCUIElementQuery的方法做下一步的篩選。

XCUIElementQuery示例.png

使用 NSPredicate 為查詢條件增加條件

// 查找所有的 collectionView 的 cell, collectionViews 和 cells 是 XCUIElementQuery 提供的方法
XCUIElementQuery *cells = app.collectionViews.cells;

// 使用 NSPredicate 為查詢條件增加條件
XCUIElementQuery *cells = [app.collectionViews.cells matchingPredicate:[NSPredicate predicateWithFormat:@"identifier LIKE '?labelPrice?'"]];

三、UI測試示例

點擊下載Demo:ZJHUnitTestDemo

3.1、使用UI Recording自動生成代碼

新建一個 UI 測試 Target,使用 UI Recording 自動生成代碼,或者也可以直接手寫。

UI測試示例UIRecoding.gif

3.2、修改UI Recording 代碼

UI Recording 的代碼識別不出中文,需要手動改下;還會點擊兩次 tag,刪除一個就好。

/// 修改 UI Recording 生成的代碼
- (void)testLogin2 {
    // 拿到當前application程序
    XCUIApplication *app = [[XCUIApplication alloc] init];
    // 點擊 "UITestDemo" 按鈕
    [app.staticTexts[@"UITestDemo"] tap];
    
    // 點擊賬號textField
    [[[[[[[[[app.windows childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeTextField] elementBoundByIndex:0] tap];
    
    // 點擊鍵盤 shift,切換大小寫
    [app.buttons[@"shift"] tap];
    
    // 點擊鍵盤 a
    XCUIElement *aKey = app.keys[@"a"];
    [aKey tap];
//    [aKey tap]; // 多余tag 需要注釋掉
    
    // 點擊密碼textField
    [[[[[[[[[[[app childrenMatchingType:XCUIElementTypeWindow] elementBoundByIndex:0] childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeTextField] elementBoundByIndex:1] tap];
    
    // 切換數字鍵盤
    XCUIElement *moreKey = app.keys[@"more"];
    [moreKey tap];
    
    // 輸入1、2、3、4
    XCUIElement *key = app.keys[@"1"];
    [key tap];
    XCUIElement *key2 = app.keys[@"2"];
    [key2 tap];
    XCUIElement *key3 = app.keys[@"3"];
    [key3 tap];
    XCUIElement *key4 = app.keys[@"4"];
    [key4 tap];
    
    // 點擊登錄按鈕
    XCUIElement *button = app.buttons[@"登錄"];
    [button.staticTexts[@"登錄"] tap];
    
    // 點擊鍵盤刪除按鈕
    XCUIElement *deleteKey = app.keys[@"delete"];
    [deleteKey tap];
    
    // 點擊登錄按鈕
    [button tap];
    
    // 點擊返回按鈕
    [app.navigationBars[@"Record List"].buttons[@"登錄"] tap];
}

3.3、精簡代碼

UI Recording 生成的代碼還不夠簡練,可以再次對其修改。也可以直接編寫,不使用UI Recording生成的

/// 精簡代碼
- (void)testLogin3 {
   // 拿到當前application程序
   XCUIApplication *app = [[XCUIApplication alloc] init];
   
   // 獲取 “UITestDemo” 按鈕,并點擊,跳轉到登錄頁面
   [app.staticTexts[@"UITestDemo"] tap];
   
   // 拿到當前app下的textfeild的搜索器
   XCUIElementQuery *tfQuery = app.textFields;
   // 賬號textField
   XCUIElement *accountTF = [tfQuery elementBoundByIndex:0];
   // 密碼textField
   XCUIElement *passwordTF = [tfQuery elementBoundByIndex:1];
   
   // 拿到當前app下的button的搜索器
   XCUIElementQuery *btnQuery = app.buttons;
   // 獲取登錄按鈕
   XCUIElement *loginBtn = btnQuery[@"登錄"];

   // 模擬UI操作
   [accountTF tap]; // 點擊賬號textField
   [accountTF typeText:@"a"]; // 輸入字母a
   [passwordTF tap];// 點擊密碼textField
   [passwordTF typeText:@"1234"]; // 輸入字母123456
   [loginBtn tap]; // 點擊登錄,提示密碼錯誤
   
   // 獲取鍵盤的刪除按鈕
   XCUIElement *deleteBtn = app.keys[@"delete"];
   [deleteBtn tap]; // 點擊一次刪除按鈕
   
   // 再次點擊登錄按鈕
   [loginBtn tap]; // 點擊登錄,成功跳轉

   // 獲取 “Record List” navigationBar,
   XCUIElement *navBarElement = app.navigationBars[@"Record List"];
   // 獲取返回按鈕
   XCUIElement *backBtn = navBarElement.buttons[@"登錄"];
   // 點擊返回按鈕
   [backBtn tap];
}

注意:如果某些UI測試失敗,請禁用“連接硬件鍵盤”選項。

為此,請在模擬器應用程序中選擇“ I / O”菜單選項,然后轉到Keyboard并取消選中Connect hardware keyboard。 連接硬件鍵盤后,UI測試似乎無法訪問模擬器中的text field

模擬器彈出鍵盤.png

四、UI測試拓展 Tips

4.1、等待預期

可以用expectationForPredicate:evaluatedWithObject:handler:方法監聽對象屬性,當滿足NSPredicate條件時,expectation相當于自動fullfill`。如果一直不滿足條件,會一直等待直至超時,除此之外還可以用通知和 KVO 的方式實現。

例如,列表中,新增一個cell數據后,可以監聽監聽app.cellscount屬性,判斷cell的個數是否按預期增加,代碼如下:

 // 暫存當前 cell 數量
 NSInteger cellsCount = app.cells.count;
 // 設置一個預期 判斷 app.cells 的 count 屬性會等于 cellsCount+1, 等待直至失敗,如果符合則不再等待
 NSPredicate *predicate = [NSPredicate predicateWithFormat:@"count == %d",cellsCount+1];
 [self expectationForPredicate:predicate evaluatedWithObject:app.cells handler:nil];
    
 // 執行添加操作,或者網絡請求等異步操作
 [self addCellData];
 
 // 等待實現預期,這里等到10s
 [self waitForExpectationsWithTimeout:10 handler:nil];

4.2、多應用聯合測試

多應用聯合測試時,依賴XCUIApplication類的以下 2 個方法:

  • initWithBundleIdentifier:
  • activate

前者可以根據 BundleId 獲取其他 App 的實例,讓我們可以啟動其他 App。后者可以讓 App 從后臺切換至前臺,在多應用間切換。簡單實現代碼如下:

- (void)testExample {
    // 返回 UI 測試 Target 設置中選中的 Target Application 的實例
    XCUIApplication *app = [[XCUIApplication alloc] init];
    
    // 使用 BundleId 獲得另外一個 App 實例:需要先創建另個測試app
    XCUIApplication *anotherApp = [[XCUIApplication alloc] initWithBundleIdentifier:@"zjh.ZJHUnitTestDemo2"];

    // 先啟動我們的主 App
    [app launch];
    
    // 做一系列測試1
    [app.staticTexts[@"UITestDemo"] tap];
    [app.navigationBars[@"登錄"].buttons[@"Home"] tap];

    sleep(2);
        
    // 啟動另一個 App
    [anotherApp activate];
    
    sleep(2);
    
    // 回到我們的主 App (在 App 未啟動的情況下調 activate 會讓 App 啟動)
    [app activate];
    
    // 做一系列測試2
    [app.staticTexts[@"UITestDemo"] tap];
    [app.navigationBars[@"登錄"].buttons[@"Home"] tap];
}

4.3、截屏

在 UI 測試中有 2 種類型支持通過代碼截屏,分別是XCUIElementXCUIScreen。

// 獲取一個截屏對象
XCUIScreenshot *screenshot = [app screenshot];

// 實例化一個附件對象 并傳入截屏對象
XCTAttachment *attachment = [XCTAttachment attachmentWithScreenshot:screenshot];

// 附件的存儲策略 如果選擇 XCTAttachmentLifetimeDeleteOnSuccess 則測試成功的情況會被刪除
attachment.lifetime = XCTAttachmentLifetimeKeepAlways;

// 設置一個名字 方便區分
attachment.name = @"MyScreenshot";

[self addAttachment:attachment];

在測試結束后,可以在 Report 導航欄中查看截圖:

查看截圖.png

除此之外 Xcode 提供了自動截圖的功能,可以幫助我們在每一個交互操作之后自動截圖。此功能會產生大量截圖,需要謹慎使用,一般情況最好勾選Delete when each test succeeds,需要在 Edit Scheme -> Test -> Options 中開啟。

4.4、被測試 app 如何判斷正在進行 UI Test

在啟動 app 時增加一個啟動參數,在 app 中讀取。

// 測試代碼
XCUIApplication *app = [[XCUIApplication alloc] init];
app.launchEnvironment = @{@"isUITest" : @YES};
[app launch];

// app 代碼
+ (BOOL)isUITesting {
    NSDictionary *environment = [[NSProcessInfo processInfo] environment];
    return [environment[@"isUITest"] boolValue];
}

五、Accessibility Inspector簡介

5.1、使用 Accessibility Inspector

Accessibility Inspector 輔助功能檢查器,通過輔助功能檢查器,您可以識別應用程序中無法訪問的部分。它提供了有關如何訪問它們的反饋,并模擬畫外音,以幫助您識別畫外音用戶的體驗。觀看在輔助功能檢查器中完全調試的應用程序的實時演示,并了解如何利用這個強大的工具使您的應用程序更適合每個人。

前文中提到 Apple 對于視圖元素會默認審查能夠通過 VoiceOver 播放文字的視圖元素,而對于 UIImageView、UIView 這種默認不支持 Accessibility 功能的需要配置相關特性,而開發人員在開發過程中可以通過 Accessibility Inspector 查看不同進程的 Accessibility 元素層級,該應用可以審查 iOS 和 macOS 的元素。

選擇 Xcode 的圖標菜單并選擇 Open Developer Tool 選項,點擊 Accessibility Inspector 即可開始使用。

打開Accessibility Inspector.png

當我們沒有設置 isAccessibilityElement 屬性時,在 Accessibility 元素層級結構中就無法看到 UIImageView 和 UIView 元素,只能看到 “t我是Button” 和“我是Label”。而當我們將 UIView 的 isAccessibilityElement 屬性設置為 YES 時, UIView 元素才能在元素層級中可見,UIImageView默認還是看不見。設置代碼如下:

    NSArray *nameArr = @[@"我是Button", @"我是Label", @"我是View", @"我是Image"];
        
        if (i == 0) { // 按鈕
            UIButton *btn = [[UIButton alloc] initWithFrame:btnF];
            [btn setTitle:nameArr[i] forState:UIControlStateNormal];
            temView = btn;
        } else if (i == 1) { // label
            UILabel *lab = [[UILabel alloc] initWithFrame:btnF];
            lab.text = nameArr[i];
            lab.textAlignment = NSTextAlignmentCenter;
            temView = lab;
        } else if (i == 2) { // view
            UIView *view = [[UIView alloc] initWithFrame:btnF];
            view.isAccessibilityElement = YES; // 將 UIView  的 isAccessibilityElement 屬性設置為 YES 
            view.accessibilityIdentifier = nameArr[I];
            temView = view;
        } else if (i == 3) { // 圖片
            UIImageView *imgView = [[UIImageView alloc] initWithFrame:btnF];
            imgView.image = [UIImage imageNamed:@"avatar"];
            imgView.accessibilityIdentifier = nameArr[i];
            temView = imgView;
        }
Accessibility Inspector使用.png

5.2、Accessibility 相關屬性

@property (nullable, nonatomic, copy) NSString *accessibilityLabel;

accessibilityLabel 屬性可以解決絕大部分的 Accessibility 問題,當光標將焦點放在設置該屬性的元素師時,它的內容可由 VoiceOver 讀取的人類可讀的字符串。但如果不是需要被視障用戶獲知的視圖元素,僅用于自動化測試,就可以不用設置該屬性。

@property(nullable, nonatomic, copy) NSString *accessibilityIdentifier API_AVAILABLE(ios(5.0));

accessibilityIdentifier 屬性不會被 VoiceOver 誦讀,而是面向開發人員的字符串,可在不希望用戶操作 accessibilityLabel 的情況下使用。

@property (nonatomic) BOOL isAccessibilityElement;

如果 isAccessibilityElement 未設置為 true,那么這個視圖將不會在 Accessibility 視圖層次結構中可見。

  • The default value for this property is false unless the element is a standard UIKit control, in which case, the value is true. —— Apple Documentation

另外,根據 Apple 官方中的介紹 UIControl 的子類的 isAccessibilityElement 屬性都默認設置為 true。

5.3、編寫測試用例

- (void)testExample {
    XCUIApplication *app = [[XCUIApplication alloc] init];
    [app.staticTexts[@"Accessibility Demo"] tap];
    
    XCUIElement *button = app.buttons[@"我是Button"];
    XCTAssertTrue(button.exists);
    XCUIElement *label = app.staticTexts[@"我是Label"];
    XCTAssertTrue(label.exists);
    XCUIElement *view = app.otherElements[@"我是View"];
    XCTAssertTrue(view.exists);
    XCUIElement *imgview = app.images[@"我是Image"];
    XCTAssertTrue(imgview.exists);
}

六、三方框架KIF簡介

6.1、KIF簡介

KIF 的全稱是Keep it functional。它是一個建立在XCTest的UI測試框架,通過accessibility來定位具體的控件,再利用私有的API來操作UI。由于是建立在XCTest上的,所以你可以完美的借助XCode的測試相關工具。

6.2、pod引入框架

pod引入框架.png
  • 必須將Target設置為Unit Test,根據GitHub官方說明。不要設置成UI Test 項目了,我這就設錯了,調了大半天才找到原因

  • 查看GitHub的ReadMe,使用Cocoapod進行安裝,命令如下(在Debug模式下才生效)

  • KIF一定要放到測試項目下面

    target 'ZJHKIFUnitTestDemoTests' do
        pod 'KIF', :configurations => ['Debug']
      end
    

6.3、簡單使用

KIF使用示例.png

更多接口介紹,可參考:KIF API中文翻譯



參考鏈接:
iOS 單元測試和 UI 測試快速入門:https://juejin.cn/post/6844903744170098695
iOS UI 自動化測試原理以及在 Trip.com 的應用實踐:https://www.51cto.com/article/686176.html
iOS UI Testing 指北:https://nixwang.com/2018/09/30/ios-ui-testing/

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容