平時寫完業務代碼的時候都會去本身測試一遍,後面每次有修改都須要重複測,不論是一個業務流程仍是一個工具類,其實均可以經過測試框架來幫助咱們完成測試,特別是一些頻繁修改的代碼,更須要嚴謹的測試。在淺淺地對自動化測試有一些瞭解時,以爲寫測試代碼挺耗時間,但其實對後期的幫助是很是大的,能夠根據本身的實際狀況來決定哪些地方須要加入自動化測試。git
本文內容適合剛接觸 iOS 自動化測試的同窗,基本內容來自於各年 WWDC 的多個 Sessions,本文代碼部分基於個人一個學習 Demo,喜歡的能夠了解一下。本文介紹的大體內容包括:github
在新建項目時,勾選Include Unit Tests
和Include UI Tests
,便可爲項目添加單元測試和 UI 測試。api
在添加測試代碼時,你須要遵照一些最基本的規則:xcode
全部的測試類須要繼承XCTestCase
安全
@interface TTTestCase : XCTestCase
複製代碼
測試方法命名以 test 開始bash
- (void)testThatMyFunctionWorks
複製代碼
用 Assertion API 進行驗證是否經過網絡
XCTAssertEqual(value, expectedValue)
複製代碼
單元測試的結構:app
// 準備輸入
NSString *dateString = @"2000-01-01";
// 須要測試的方法
BOOL isToday = [TTDateFormatter isTodayWithDateString:dateString];
// 驗證輸出
XCTAssert(isToday, @"isToday false");
複製代碼
以上三個部分的代碼準備完成後便可開始測試,啓動的方式有不少種,能夠根據你的實際狀況選擇如下方式:框架
⌘ + U
測試所有用例性能測試經過度量代碼塊執行所消耗的時間長短,來衡量是否經過測試。異步
相關 API :
measureBlock:
- (void)testPerformanceOfMyFunction {
[self measureBlock:^{
// Do that thing you want to measure.
MyFunction();
}];
}
複製代碼
measureMetrics:automaticallyStartMeasuring:forBlock:
- (void)testMyFunction2_WallClockTime {
[self measureMetrics:[self class].defaultPerformanceMetrics automaticallyStartMeasuring:NO forBlock:^{
// Do setup work that needs to be done for every iteration but you don't want to measure before the call to -startMeasuring SetupSomething(); [self startMeasuring]; // Do that thing you want to measure. MyFunction(); [self stopMeasuring]; // Do teardown work that needs to be done for every iteration but you don't want to measure after the call to -stopMeasuring
TeardownSomething();
}];
}
複製代碼
全部的性能測試須要設置一個Baseline
來驗證是否經過測試,沒有設置的會提示No baseline average for Time
。
咱們能夠經過點擊measureBlock:
方法左邊菱形圓心 icon ,來設置Baseline
,設置以後須要點擊save
保存。以後再執行測試用例時,若是成功,左邊的icon會從圓心變成一個 ✅。
何時須要使用異步測試:
異步測試分爲3個部分: 新建指望 、 等待指望被履行 和 履行指望 。
XCTestExpectation :測試指望,能夠由測試類持有,也能夠本身持有,本身持有測試指望時靈活性更好一些,你能夠選擇等待哪些指望。
// 測試類持有的初始化方法
XCTestExpectation *expect1 = [self expectationWithDescription:@"asyncTest1"];
// 本身持有的初始化方法
XCTestExpectation *expect2 = [[XCTestExpectation alloc] initWithDescription:@"asyncTest3"];
複製代碼
waitForExpectations:timeout: :等待異步的指望代碼執行,根據初始化方式不一樣,等待的方法不一樣。
// 測試類持有時的等待方法
[self waitForExpectationsWithTimeout:10.0 handler:nil];
// 本身持有時的等待方法
[self waitForExpectations:@[expect3] timeout:10.0];
複製代碼
fulfill :履行指望,而且適當加入XCTAssertTrue
等斷言,來驗證測試結果。
XCTestExpectation *expect3 = [[XCTestExpectation alloc] initWithDescription:@"asyncTest3"];
[TTFakeNetworkingInstance requestWithService:apiRecordList completionHandler:^(NSDictionary *response) {
XCTAssertTrue([response[@"code"] isEqualToString:@"200"]);
[expect3 fulfill];
}];
[self waitForExpectations:@[expect3] timeout:10.0];
複製代碼
XCTWaiter
是 2017 年新增的異步測試方案,能夠經過代理方式來處理異常狀況。
XCTWaiter *waiter = [[XCTWaiter alloc] initWithDelegate:self];
XCTestExpectation *expect4 = [[XCTestExpectation alloc] initWithDescription:@"asyncTest3"];
[TTFakeNetworkingInstance requestWithService:@"product.list" completionHandler:^(NSDictionary *response) {
XCTAssertTrue([response[@"code"] isEqualToString:@"200"]);
expect4 fulfill];
}];
XCTWaiterResult result = [waiter waitForExpectations:@[expect4] timeout:10 enforceOrder:NO];
XCTAssert(result == XCTWaiterResultCompleted, @"failure: %ld", result);
複製代碼
XCTWaiterDelegate:若是委託是XCTestCase
實例,下方代理被調用時會報告爲測試失敗。
// 若是有指望超時,則調用。
- (void)waiter:(XCTWaiter *)waiter didTimeoutWithUnfulfilledExpectations:(NSArray<XCTestExpectation *> *)unfulfilledExpectations;
// 當履行的指望被強制要求按順序履行,但指望以錯誤的順序被履行,則調用。
- (void)waiter:(XCTWaiter *)waiter fulfillmentDidViolateOrderingConstraintsForExpectation:(XCTestExpectation *)expectation requiredExpectation:(XCTestExpectation *)requiredExpectation;
// 當某個指望被標記爲被倒置,則調用。
- (void)waiter:(XCTWaiter *)waiter didFulfillInvertedExpectation:(XCTestExpectation *)expectation;
// 當 waiter 在 fullfill 和超時以前被打斷,則調用。
- (void)nestedWaiter:(XCTWaiter *)waiter wasInterruptedByTimedOutWaiter:(XCTWaiter *)outerWaiter;
複製代碼
在執行測試用例後,Xcode 會返回給咱們測試結果,能夠經過一下途徑查看:
除此以外,咱們還能夠在 Report 導航欄中查看更加詳細的測試報告:
我新建一個時間工具類,幫助我轉換時間,在使用以前,咱們須要先進行測試,以保證功能完整且正確。
這個工具類有如下 4 個公共方法,
@interface TTDateFormatter : NSDate
+ (NSString *)stringFormatWithDate:(NSDate *)date;
+ (NSDate *)dateFormatWithString:(NSString *)dateString;
+ (BOOL)isTodayWithDateString:(NSString *)dateString;
+ (NSString *)getHowLongAgoWithTimeStamp:(NSTimeInterval)timeStamp;
@end
複製代碼
針對一個工具類的測試咱們能夠新建一個TTDateFormatterTests
測試類,繼承一個測試基類。再根據不一樣的方法寫不一樣的測試方法。若是有if
和switch
等條件語句致使邏輯分支的代碼,儘可能使各個邏輯分支都能測試到,能夠配合代碼覆蓋率來檢查哪些邏輯分支未測試。
@interface TTDateFormatterTests : TTTestCase
@end
@implementation TTDateFormatterTests
- (void)testDateFormatter {
NSString *originDateString = @"2018-06-06 20:20:20";
NSDate *date = [TTDateFormatter dateFormatWithString:originDateString];
NSString *dateString = [TTDateFormatter stringFormatWithDate:date];
XCTAssertEqualObjects(dateString, originDateString);
}
- (void)testDateFormatterIsToday {
NSString *dateString = [TTDateFormatter stringFormatWithDate:[NSDate date]];
XCTAssertTrue([TTDateFormatter isTodayWithDateString:dateString]);
XCTAssertFalse([TTDateFormatter isTodayWithDateString:@"2000-01-01"]);
}
- (void)testDateFormatterHowLongAgo {
// 該方法中包含一個 switch ,要保證 switch 每一個邏輯分支都測試到,因此須要多個測試。
NSDate *now = [NSDate date];
NSString *secAgo = [TTDateFormatter getHowLongAgoWithTimeStamp:now.timeIntervalSince1970 - 10 * sec];
XCTAssertEqualObjects(secAgo, @"10秒前");
NSString *minAgo = [TTDateFormatter getHowLongAgoWithTimeStamp:now.timeIntervalSince1970 - 15 * min];
XCTAssertEqualObjects(minAgo, @"15分鐘前");
NSString *hourAgo = [TTDateFormatter getHowLongAgoWithTimeStamp:now.timeIntervalSince1970 - 20 * hour];
XCTAssertEqualObjects(hourAgo, @"20小時前");
NSString *dayAgo = [TTDateFormatter getHowLongAgoWithTimeStamp:now.timeIntervalSince1970 - 25 * hour];
XCTAssertEqualObjects(dayAgo, @"1天前");
NSString *daysAgo = [TTDateFormatter getHowLongAgoWithTimeStamp:now.timeIntervalSince1970 - 50 * hour];
XCTAssertEqualObjects(daysAgo, @"2天前");
NSString *longTimeAgo = [TTDateFormatter getHowLongAgoWithTimeStamp:1544002463];
XCTAssertEqualObjects(longTimeAgo, @"2018-12-05 17:34:23");
}
@end
複製代碼
合理使用測試基類和測試工具類,能夠避免大量重複測試代碼。時間轉換工具類是一個沒有外部依賴的類,當一些對外部有依賴的類須要測試時,能夠嘗試 OCMock ,它能幫助你模擬數據。另外,當你以爲測試框架提供的斷言方法沒法知足你時,也能夠試着使用 OCHamcrest 。
何時須要使用 UI 測試:
UI 測試的步驟:
經過 UI Recording ,能夠將你操做手機的行爲記錄下來,而且轉換成代碼,能夠幫助你快速生成 UI 測試代碼。
選中 UI 測試類,你能再下方看到一個小紅點,點擊小紅點開始錄製你的交互。
在你進行交互時,Xcode 會自動轉化成代碼,你能夠藉此建立新的測試代碼,也能夠以此拓展已經存在的測試代碼。固然它也不是十分完美,並非總能如你所願,還須要你作一些處理,好比說自動生成的代碼過於繁瑣,你能夠用一些更簡潔的代碼實現。即便這樣,UI Recording 也是很是高效的方式。
XCUIApplication
能夠返回一個應用程序實例,而後你就能夠經過測試代碼啓動應用程序。
// 返回 UI 測試 Target 設置中選中的 Target Application 的實例
- (instancetype)init;
// 根據 bundleId 返回一個應用程序實例
- (instancetype)initWithBundleIdentifier:(NSString *)bundleIdentifier;
// 啓動應用程序
- (void)launch;
// 將應用程序喚醒至前臺,在多程序聯合測試下會用到
- (void)activate;
// 結束一個正在運行的應用程序
- (void)terminate;
複製代碼
應用程序中的 UI 控件,控件類型多樣,多是Button
,Cell
,Window
等等。該類實例有不少模擬交互的方法,如tap
模擬用戶點擊事件,swipe
模擬滑動事件,typeText:
模擬用戶輸入內容。
在 UI 測試中咱們須要找到某個空間,能夠經過他們的類型來縮小範圍,好比當前頁面有且只有一個UITextView
控件,你能夠經過如下代碼來獲取:
XCUIApplication *app = [[XCUIApplication alloc] init];
[app launch];
// 若是是 Cell 則對應 app.cells
// firstMatch 返回第一個符合的控件
XCUIElement *textView = app.textViews.firstMatch;
// 模擬用戶在 textView 輸入內容
[textView typeText:@"input string"];
複製代碼
另外還有一種方式經過 Accessibility identifer, label, title 等等方式來定位對應的控件,如尋找一個名爲 Add 的button
。
// 須要勾選 Accessibility Enabled ,而且在 Label 一欄填入 Add
XCUIElement *addButton = app.buttons[@"add"];
// 模擬用戶點擊按鈕
[addButton tap];
複製代碼
經過類型加 identifier 的方式來定位的控件元素的方式,能夠知足大多數場景。
XCUIElementQuery
是一個用來定位控件元素的類,通常是一組符合篩選條件的元素集合。如app.buttons
即返回 XCUIElementQuery 實例,是包含了當前全部的button
的集合,你能夠再經過 XCUIElementQuery
的方法作下一步的篩選。
XCUIElementQuery 常見定位元素的方法:
count:匹配的數量;
// 當 navigationBars 的 count 等於 1 時,你能夠直接定位到 navigationBar
app.navigationBars.element
複製代碼
subscripting:經過 id 來定位
table.staticTexts["Groceries"]
複製代碼
index:經過元素的下標來定位
table.staticTexts.elementAtIndex(0)
複製代碼
定位元素除了利用元素類型、Accessibility Identifiers,Predicates 等篩選方法,還能夠結合嵌套的層級關係來幫助定位。
要進行 UI 測試須要如下幾個步驟:
XCTAssert
等斷言邏輯,驗證測試是否經過。let app = XCUIApplication()
// 啓動 app
app.launch()
// 定位元素
let addButton = app.buttons[「Add」]
// 模擬用戶交互事件
addButton.tap()
// 驗證測試是否經過
XCTTAssertionEqual(app.tables.cells.cout, 1)
複製代碼
大多數 UI 測試都是基於用戶行爲驅動,根據設計好的用戶的操做流程,測試整個流程的結果。我設計了一個簡單的筆記,主要有 3 步操做,分別是建立筆記、展現筆記和刪除筆記,下面一塊兒來看看如何進行測試。
// 測試主流程
- (void)testMainFlow {
// 啓動 app
XCUIApplication *app = [[XCUIApplication alloc] init];
[app launch];
// 添加筆記
[self addRecordWithApp:app msg:@"今每天氣真好!🌞"];
[self addRecordWithApp:app msg:@"今天詹姆斯特別給力,帶領球隊走向勝利。✌️"];
while (app.cells.count > 0) {
// 刪除筆記
[self deleteFirstRecordWithApp:app];
}
}
/**
添加筆記
@param app app 實例
@param msg 筆記內容
*/
- (void)addRecordWithApp:(XCUIApplication *)app msg:(NSString *)msg {
// 暫存當前 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];
// 定位導航欄+號按鈕,點擊進入添加筆記頁面
XCUIElement *addButton = app.navigationBars[@"Record List"].buttons[@"Add"];
[addButton tap];
// 測試 未輸入任何內容點擊保存
[app.navigationBars[@"Write Anything"].buttons[@"Save"] tap];
// 定位文本輸入框 輸入內容
XCUIElement *textView = app.textViews.firstMatch;
[textView typeText:msg];
// 保存
[app.navigationBars[@"Write Anything"].buttons[@"Save"] tap];
// 等待預期
[self waitShortTimeForExpectations];
}
/**
刪除最近一個筆記
@param app app 實例
*/
- (void)deleteFirstRecordWithApp:(XCUIApplication *)app {
NSInteger cellsCount = app.cells.count;
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"count == %d",cellsCount-1];
// 設置一個預期 判斷 app.cells 的 count 屬性會等於 cellsCount-1, 等待直至失敗,若是符合則再也不等待
[self expectationForPredicate:predicate evaluatedWithObject:app.cells handler:nil];
// 定位到 cell 元素
XCUIElement *firstCell = app.cells.firstMatch;
// 左滑出現刪除按鈕
[firstCell swipeLeft];
// 定位刪除按鈕
XCUIElement *deleteButton = [app.buttons matchingIdentifier:@"Delete"].firstMatch;
// 點擊刪除按鈕
if (deleteButton.exists) {
[deleteButton tap];
}
// 等待預期
[self waitShortTimeForExpectations];
}
複製代碼
在上面的邏輯中涉及到異步的請求,咱們能夠經過利用expectationForPredicate:evaluatedWithObject:handler:
方法監聽app.cells
的count
屬性,當知足NSPredicate
條件時,expectation
至關於自動fullfill
。若是一直不知足條件,會一直等待直至超時,除此以外還能夠用通知和 KVO 的方式實現。
測試過程:
多應用聯合測試時,依賴XCUIApplication
類的如下 2 個方法:
前者能夠根據 BundleId 獲取其餘 App 的實例,讓咱們能夠啓動其餘 App。後者可讓 App 從後臺切換至前臺,在多應用間切換。簡單實現代碼以下:
// 返回 UI 測試 Target 設置中選中的 Target Application 的實例
XCUIApplication *ttApp = [[XCUIApplication alloc] init];
// 使用 BundleId 得到另一個 App 實例
XCUIApplication *anotherApp = [[XCUIApplication alloc] initWithBundleIdentifier:@"Another.App.BundleId"];
// 先啓動咱們的主 App
[ttApp launch];
// 作一系列測試
// 啓動另外一個 App
[anotherApp launch];
// 作一系列測試
// 回到咱們的主 App (在 App 未啓動的狀況下調 activate 會讓 App 啓動)
[ttApp activate];
複製代碼
在一些邏輯比較複雜的測試中,咱們能夠藉助XCTContext
類來幫咱們把測試邏輯分割成多個小的測試模塊。好比說咱們有一個業務,關聯多個模塊,這個時候咱們能夠用相似下面的代碼來處理:
// 模塊 1
[XCTContext runActivityNamed:@"step1" block:^(id<XCTActivity> _Nonnull activity) {
XCTestExpectation *expect1 = [self expectationWithDescription:@"asyncTest1"];
[TTFakeNetworkingInstance requestWithService:apiRecordSave completionHandler:^(NSDictionary *response) {
XCTAssertTrue([response[@"code"] isEqualToString:@"200"]);
[expect1 fulfill];
}];
}];
// 模塊 2
[XCTContext runActivityNamed:@"step2" block:^(id<XCTActivity> _Nonnull activity) {
XCTestExpectation *expect2 = [self expectationWithDescription:@"asyncTest2"];
[TTFakeNetworkingInstance requestWithService:apiRecordDelete completionHandler:^(NSDictionary *response) {
XCTAssertTrue([response[@"code"] isEqualToString:@"200"]);
[expect2 fulfill];
}];
}];
[self waitShortTimeForExpectations];
複製代碼
若是測試成功,能夠在 Report 導航欄看到成功信息,它會按照你設置的模塊分別展現測試結果。
若是測試失敗,你能夠看到哪些模塊是成功的,和在哪些模塊中失敗了。
除此以外,你還能夠嘗試多層嵌套,activity 裏面嵌套 activity。
在 UI 測試中有 2 種類型支持經過代碼截屏,分別是XCUIElement
和XCUIScreen
。
// 獲取一個截屏對象
XCUIScreenshot *screenshot = [app screenshot];
// 實例化一個附件對象 並傳入截屏對象
XCTAttachment *attachment = [XCTAttachment attachmentWithScreenshot:screenshot];
// 附件的存儲策略 若是選擇 XCTAttachmentLifetimeDeleteOnSuccess 則測試成功的狀況會被刪除
attachment.lifetime = XCTAttachmentLifetimeKeepAlways;
// 設置一個名字 方便區分
attachment.name = @"MyScreenshot";
[self addAttachment:attachment];
複製代碼
在測試結束後,能夠在 Report 導航欄中查看截圖:
除此以外 Xcode 提供了自動截圖的功能,能夠幫助咱們在每個交互操做以後自動截圖。此功能會產生大量截圖,須要謹慎使用,通常狀況最好勾選Delete when each test succeeds
,須要在 Edit Scheme -> Test -> Options 中開啓。
因此你能夠根據你的需求選擇適當的截圖策略。
代碼覆蓋率在 Report 導航欄中查看,它除了能夠幫你統計測試用例覆蓋的代碼百分比,還能夠幫助你發現哪些代碼是沒有被測試用例覆蓋的,須要在 Edit Scheme -> Test -> Options 中開啓。
你還能夠選擇統計哪些 targets 的代碼覆蓋率,all targets
表示統計項目內全部 targets 的覆蓋率,some targets
須要你手動添加 target ,只統計手動添加的 target 的覆蓋率。
除了在 Report 導航欄中查看代碼覆蓋率的方式,你還能夠藉助蘋果提供的命令行工具xccov
來生成代碼覆蓋率報告。值得一提的是,xccov
還能輸出 JSON 格式的報告。
在 Xcode 10 中新增功能,在 Edit Scheme -> Test -> Info -> Tests 中能夠經過取消勾選,來選擇跳過部分測試用例。在 target 的 Options 選項中,Automatically includes new tests
,選項是默認勾選的,新建的測試文件會自動添加進去。
默認狀況下,測試用例執行的順序是按字母順序來執行的,按固定順序執行可能會使一些隱式的依賴關係沒法被發現。如今有了隨機的執行順序,就能夠挖掘出那些隱式的依賴關係。能夠在 Edit Scheme -> Test -> Info -> Tests -> Options 中開啓該功能。
並行測試能夠同時進行多個測試,從而節省大量時間。在測試時會啓動多個模擬器,模擬器之間的數據都是隔離的,能夠在 Edit Scheme -> Test -> Info -> Tests -> Options 中開啓該功能。
對於並行測試的一些建議:
單元測試的結構:
可測試代碼的特徵:
本節內容根據 WWDC 2017 Session 414:Engineering for Testability 粗略總結得出,若是須要了解更多相關內容能夠查看相關視頻。
掌握這些測試相關 API 並不難,可是好的代碼須要通過完整項目的磨礪和時間的考驗。同時也能夠借鑑一些開源項目的測試代碼,嘗試着爬上巨人的肩膀。
WWDC 2018 Session 403:What's New in Testing
WWDC 2017 Session 414:Engineering for Testability
WWDC 2017 Session 409:What's New in Testing