iOS開發之進階篇(6)—— 單元測試(Unit Tests 和 UI Tests)

版本

Xcode 11.5

1. 概念

1.1 單元測試

單元測試是指對軟件中的最小可測試單元進行檢查和驗證. Xcode中有兩種單元測試 (Unit Tests 和 UI Tests), Unit Tests 用於測試功能模塊; UI Tests用於測試UI交互.

  • Unit Tests 用於測試功能模塊, 這些功能模塊應儘量單一, 避免與其他功能耦合. 比如測試一個比大小的函數, 一個請求網絡的功能等等.
  • UI Tests 用於UI交互. 它可以通過編寫代碼或者是記錄開發者的手動操作過程並代碼化, 來實現自動點擊某個按鈕、視圖, 或者自動輸入文字等功能.

1.2 測試用例

指我們用於測試某個功能或者UI交互的測試代碼.

1.3 斷言

斷言主要作用是可以讓開發者比較便捷的捕獲一個錯誤, 讓程序崩潰, 同時報出錯誤提示. 如果某個斷言不通過, 程序將報錯, 並定格在斷言所在行.
斷言只在debug模式下起作用, 在release模式下將被忽略.
一些常用的斷言:

XCTAssertNil(expression, ...) expression爲空時通過, ...可填入報錯信息
XCTAssertNotNil(expression, ...)  expression不爲空時通過
XCTAssert(expression, ...)  expression爲true時通過
XCTAssertTrue(expression, ...)  expression爲true時通過
XCTAssertFalse(expression, ...)  expression爲false時通過
XCTAssertEqual(expression1, expression2, ...)  expression1 = expression2 時通過
XCTAssertEqualObjects(expression1, expression2, ...)  expression1 = expression2 時通過
XCTAssertNotEqualObjects(expression1, expression2, ...)  expression1 != expression2 時通過

2. 準備工作

新建工程, 並勾選 Include Unit Tests 和 Include UI Tests, 然後系統將會自動創建單元測試target及模板代碼.

create.png

如果新建工程時沒有勾選那兩項單元測試, 我們也可以後期添加之. 點擊TARGET添加按鈕:

create2.png

然後找到那兩個單元測試:

create3.png

添加後就可看到測試代碼塊:

tests.png

這裏Unit Tests的代碼塊文件夾名爲工程名+Tests; 而UI Tests的代碼塊文件夾名爲工程名+UITests.
並且每個文件夾下都各自默認創建了一個測試類plist文件, 測試類只有.m文件而沒有.h文件, 因爲單元測試不需要外部來調用, 我們所有的測試工作都在.m文件裏面完成.
一個測試類 (.m文件) 裏面可以寫很多個測試用例. 但如果測試用例太多, 我們可以創建多個測試類以便於分類管理這些測試用例. 比如有專門用於測試算法函數的, 有專門用於測試各種網絡請求功能的, 有專門用於測試工具類各功能是否正常的等等.
新建測試類:

create4.png

例如:

ms.png

3. Unit Tests

測試類繼承於XCTestCase, 並且系統一開始就給出瞭如下示例代碼:

- (void)setUp {
    // 測試用例開始前執行 (初始化)
}

- (void)tearDown {
    // 測試用例結束後執行 (清理工作)
}

- (void)testExample {
    // 測試用例
}

- (void)testPerformanceExample {
    // 性能測試
    [self measureBlock:^{
        // 性能測試對象 (代碼段)
    }];
}

我們的測試用例必須以test開頭, 這樣纔會被系統識別爲測試用例. 測試用例方法前面有一個菱形小框, 點擊這個將會執行該測試用例, 測試通過則打V, 不通過則打X. 我們也可以⌘U來測試當前類所有的用例.

示例1
測試一個簡單的比大小函數, 看測試結果是否正確.

#import "KKAlgorithm.h"

// 最大值
- (void)testMaxValue {
    
    int value1 = 5;
    int value2 = 10;
    int maxInt = [KKAlgorithm maxValueWithValue1:value1 value2:value2];
        
    XCTAssertEqual(maxInt, value2);
//    XCTAssertEqual(maxInt, value1, @"返回最大值錯誤!");
}

maxInt=value2=10, 測試通過. 如果把value2改爲value1, 則測試不通過, 程序報錯並定格在XCTAssertEqual所在行.

KKAlgorithm.h

@interface KKAlgorithm : NSObject

// 獲取最大值
+ (int)maxValueWithValue1:(int)value1 value2:(int)value2;

@end

KKAlgorithm.m

// 獲取最大值
+ (int)maxValueWithValue1:(int)value1 value2:(int)value2 {
    
    return value1 > value2 ? value1 : value2;
}

示例2
請求網絡. 因爲請求網絡是異步的, 我們需要等到網絡返回纔會判斷測試結果. 所以本例中會引入測試等待的方法.
等待方法:

    XCTWaiter *waiter = [[XCTWaiter alloc] initWithDelegate:self];
    XCTestExpectation *expectation1 = [[XCTestExpectation alloc] initWithDescription:@"請求網絡1"];
    XCTestExpectation *expectation2 = [[XCTestExpectation alloc] initWithDescription:@"請求網絡2"];
    // 異步模擬請求網絡1
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0*NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [expectation1 fulfill];     // 滿足期望
    });
    // 異步模擬請求網絡2
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0*NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [expectation2 fulfill];     // 滿足期望
    });
    // 等待10秒, 如果expectation1和expectation2都滿足期望, 則繼續往下執行
    [waiter waitForExpectations:@[expectation1, expectation2] timeout:10.0];

XCTestExpectation測試期望, 相當於等待的條件.
XCTWaiter用於發動等待, 可以設置等待多個測試期望. 有代理方法, 但這裏沒有使用到故不詳解.

請求百度首頁數據的demo:

#import "KKHttp.h"

@interface KKTestsDemoTests : XCTestCase <KKHttpDelegate> {
    
    XCTestExpectation *_expectation;
}

// 網絡測試
- (void)testHttp {
    
    XCTWaiter *waiter = [[XCTWaiter alloc] initWithDelegate:self];
    _expectation = [[XCTestExpectation alloc] initWithDescription:@"請求百度首頁數據"];
    
    // 發起網絡請求
    KKHttp *http = [[KKHttp alloc] init];
    http.delegate = self;
    [http fetchBaidu];
    
    // 等待10秒, 如果expectation滿足期望, 則繼續往下執行
    [waiter waitForExpectations:@[_expectation] timeout:10.0];
}

// 代理回調: 網絡返回
- (void)http:(KKHttp *)http receiveData:(NSData *)data error:(NSError *)error {
    
    XCTAssertNotNil(data, @"網絡無響應");
    NSString *dataStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    NSLog(@"dataStr:%@", dataStr);  // XML數據, 這裏沒進行解析
    
    [_expectation fulfill];     // 結束等待
}

因爲測試希望XCTestExpectation在代理回調中滿足, 所以期望對象設爲全局變量XCTestExpectation *_expectation;.

KKHttp.h

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@class KKHttp;
@protocol KKHttpDelegate <NSObject>
@optional
- (void)http:(KKHttp *)http receiveData:(nullable NSData *)data error:(nullable NSError *)error;
@end

@interface KKHttp : NSObject

@property (nonatomic, weak) id<KKHttpDelegate>  delegate;

- (void)fetchBaidu;

@end

NS_ASSUME_NONNULL_END

KKHttp.m

#import "KKHttp.h"

@implementation KKHttp

- (void)fetchBaidu {
    
    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"https://www.baidu.com"] cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:10.0];
    NSURLSession *session = [NSURLSession sharedSession];
    NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if ([self.delegate respondsToSelector:@selector(http:receiveData:error:)]) {
            [self.delegate http:self receiveData:data error:error];
        }
    }];
    [task resume];
}

@end

示例3
實例2的等待方法畢竟很麻煩, 如果測試用例多了, 會產生很多重複代碼. 下面討論用通知的方法來等待, 然後把通知做成宏的形式, 方便調用.
宏前傳:

// 異步測試
- (void)testAlbumAuthorization {
       
    // 異步任務
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"222");
        // 發送通知: 結束等待
        [[NSNotificationCenter defaultCenter] postNotificationName:@"KKExpectationNotification" object:nil];
    });
    
    // 等待
    [self expectationForNotification:@"KKExpectationNotification" object:nil handler:nil];
    [self waitForExpectationsWithTimeout:5.0 handler:nil];
    NSLog(@"111");
}

注意
expectationForNotification:object:handler:和waitForExpectationsWithTimeout:handler:要比postNotificationName:object:先執行. 也就是說, 要保證先打印111再打印222, 不然達不到我們預期效果.

我們把等待和結束等待寫成宏的形式, 方便別的測試用例調用:

#define WAIT \
    [self expectationForNotification:@"KKExpectationNotification" object:nil handler:nil]; \
    [self waitForExpectationsWithTimeout:5.0 handler:nil];

#define NOTIFY \
    [[NSNotificationCenter defaultCenter] postNotificationName:@"KKExpectationNotification" object:nil];

// 異步測試
- (void)testAlbumAuthorization {
       
    // 異步任務
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"222");
        // 發送通知: 結束等待
        NOTIFY
    });
    
    // 等待
    WAIT
    NSLog(@"111");
}

ps
有時候在測試對象中import別的對象的時候, 系統會提示找不到頭文件:

Nofound.png

這時候我們可以在單元測試的TARGET–>Build settings–>Header Search Paths中添加需要的頭文件. 例如:

headers.png

4. UI Tests

先來看看我們想要達到的效果:

run.gif

建立兩個VC: VC1和VC2. 在VC1裏輸入賬號和密碼然後點擊登錄, 跳轉到VC2, 接着點擊VC2的Back按鈕返回到VC1. 如此循環一萬次, 測試我們的登錄API有無問題.

在UI Tests的測試用例裏, 把光標扔進測試用例代碼區, 然後點擊小紅圈開始錄製App界面.

record.png

我們每對App屏幕互動一次, 代碼區就會自動生成對應代碼:

- (void)testExample {
    
    // 運行App
    XCUIApplication *app = [[XCUIApplication alloc] init];
    [app launch];

    
    XCUIApplication *app = [[XCUIApplication alloc] init];
    XCUIElement *element = [[[[[app childrenMatchingType:XCUIElementTypeWindow] elementBoundByIndex:0] childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element;
    [[[element childrenMatchingType:XCUIElementTypeTextField] elementBoundByIndex:0] tap];
    
    XCUIElement *aKey = app/*@START_MENU_TOKEN@*/.keys[@"a"]/*[[".keyboards.keys[@\"a\"]",".keys[@\"a\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/;
    [aKey tap];
    [aKey tap];
    [aKey tap];
    [aKey tap];
    [[[element childrenMatchingType:XCUIElementTypeTextField] elementBoundByIndex:1] tap];
    
    XCUIElement *bKey = app/*@START_MENU_TOKEN@*/.keys[@"b"]/*[[".keyboards.keys[@\"b\"]",".keys[@\"b\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/;
    [bKey tap];
    [bKey tap];
    [bKey tap];
    [bKey tap];
    [app/*@START_MENU_TOKEN@*/.staticTexts[@"\U767b\U5f55"]/*[[".buttons[@\"\\U767b\\U5f55\"].staticTexts[@\"\\U767b\\U5f55\"]",".staticTexts[@\"\\U767b\\U5f55\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/ tap];
    [app.buttons[@"Back"] tap];
    
}

當然這些代碼是raw的, 我們需要稍微做些修改纔行:

- (void)testExample {
    
    // 運行App
    XCUIApplication *app = [[XCUIApplication alloc] init];
    [app launch];

    for (int i=0; i<10; i++) {
        
        // 界面中的元素 (控件)
        XCUIElement *element = [[[[[app childrenMatchingType:XCUIElementTypeWindow] elementBoundByIndex:0] childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element;
        
        // 找到第一個輸入框, 並點擊
        [[[element childrenMatchingType:XCUIElementTypeTextField] elementBoundByIndex:0] tap];
        
        sleep(1);   // 等待鍵盤彈出
        
        // 點擊鍵盤
        XCUIElement *aKey = app/*@START_MENU_TOKEN@*/.keys[@"a"]/*[[".keyboards.keys[@\"a\"]",".keys[@\"a\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/;
        [aKey tap];
        
        // 找到第二個輸入框, 並點擊
        [[[element childrenMatchingType:XCUIElementTypeTextField] elementBoundByIndex:1] tap];
        
        // 點擊鍵盤
        XCUIElement *bKey = app/*@START_MENU_TOKEN@*/.keys[@"b"]/*[[".keyboards.keys[@\"b\"]",".keys[@\"b\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/;
        [bKey tap];
        
        // 找到登錄按鈕, 並點擊
        [app.staticTexts[@"\U0000767b\U00005f55"] tap];
//        [app.staticTexts[@"登錄"] tap];
        
        sleep(2);   // 等待加載VC2
        
        // 這時候已經跳轉到VC2了
        // 找到Back按鈕, 並點擊
        [app.buttons[@"Back"] tap];
    }
}

注意:

  1. 有些地方使用sleep等待界面加載出來, 不然在屏幕中找不到改控件會報錯;
  2. 中文登錄按鈕被自動生成@"\U767b\U5f55", 我們可以手動加入四個0或者直接寫成中文.
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章