版本
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及模板代碼.
如果新建工程時沒有勾選那兩項單元測試, 我們也可以後期添加之. 點擊TARGET添加按鈕:
然後找到那兩個單元測試:
添加後就可看到測試代碼塊:
這裏Unit Tests的代碼塊文件夾名爲工程名+Tests; 而UI Tests的代碼塊文件夾名爲工程名+UITests.
並且每個文件夾下都各自默認創建了一個測試類plist文件, 測試類只有.m文件而沒有.h文件, 因爲單元測試不需要外部來調用, 我們所有的測試工作都在.m文件裏面完成.
一個測試類 (.m文件) 裏面可以寫很多個測試用例. 但如果測試用例太多, 我們可以創建多個測試類以便於分類管理這些測試用例. 比如有專門用於測試算法函數的, 有專門用於測試各種網絡請求功能的, 有專門用於測試工具類各功能是否正常的等等.
新建測試類:
例如:
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別的對象的時候, 系統會提示找不到頭文件:
這時候我們可以在單元測試的TARGET–>Build settings–>Header Search Paths中添加需要的頭文件. 例如:
4. UI Tests
先來看看我們想要達到的效果:
建立兩個VC: VC1和VC2. 在VC1裏輸入賬號和密碼然後點擊登錄, 跳轉到VC2, 接着點擊VC2的Back按鈕返回到VC1. 如此循環一萬次, 測試我們的登錄API有無問題.
在UI Tests的測試用例裏, 把光標扔進測試用例代碼區, 然後點擊小紅圈開始錄製App界面.
我們每對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];
}
}
注意:
- 有些地方使用sleep等待界面加載出來, 不然在屏幕中找不到改控件會報錯;
- 中文登錄按鈕被自動生成@"\U767b\U5f55", 我們可以手動加入四個0或者直接寫成中文.