差不多四個月以前,我們團隊 (Marco, Arne 和 Daniel) 開始著手為我們的新應用寫模型層。我們想在開發(fā)中使用測試,經(jīng)過一番討論之后,我們選擇 XCTest 作為我們的測試框架。
目前為止,我們的編碼庫已經(jīng)縱橫 190 個文件和 18,000 行代碼,達到了 544 kB。我們測試部分的代碼現(xiàn)在差不多有1,200 kB,大概有被測試代碼的兩倍。雖然我們還沒有完全結束這個項目,但是已經(jīng)接近尾聲。在這里我們想和大家分享在這過程中我們所學到的東西,包括一般性的測試和如何用 XCTest 來做測試。
這里需要注意的是在文章中提到的一些模型類和方法已經(jīng)被重命名了,因為這個項目還沒有在 App Store 中上線。
我們選擇 XCTest 作為我們的測試框架是因為它非常簡單并且與 Xcode 的 IDE 直接集成。通過這篇文章,我們想對于何時 XCTest 會是測試框架中的好的選擇,以及何時你們可能會選擇其他的框架這一問題作出一些闡釋。
我們還在文章中合適的地方添加了這個話題里的其他文章的鏈接。
就像這篇關于糟糕的測試的文章中提到的那樣,很多人都認為“只有當我們的改變代碼時,測試才能產(chǎn)生回報?!?如果你有這樣的想法,你應該仔細讀讀那篇文章,因為顯然通過測試你所能獲得的比這要多。有一點是非常重要的,就算我們在寫代碼的最早版本,我們還是會將大部分時間花在修改代碼上 -- 隨著項目的發(fā)展,越來越多的功能會被加進來,我們會發(fā)現(xiàn)很多地方都需要稍微改一下。所以即使你還沒有在做 1.1 或 2.0 版本,但你還是要做大量的修改,而測試正是在這時為我們提供不可估量的幫助。
我們依然還在完成我們框架的最初版本,在超過 10 個人月的努力下,我們通過了將近 1,000 個測試。現(xiàn)在已經(jīng)有一個比較清楚的架構,但是我們?nèi)匀恍枰刂@個方向,去修改和調(diào)整我們的代碼。這套不斷增長的測試用例會幫我們做到這一點。
測試用例使我們的代碼質(zhì)量變得可靠,同時讓我們能夠放心地重構或者修改代碼,并保證我們的修改沒有破壞其他部分。而且我們可以在項目開始的第一天就能運行我們的代碼,而不用等到萬事俱備。
蘋果提供了一些關于如何使用 XCTest 的官方文檔。測試用例被分到繼承 XCTestCase
的不同子類中去。每個以 test
為開頭的方法都是一個測試用例。
因為測試用例都是簡單的類和方法,所以我們可以適當?shù)靥砑右恍?@property
和輔助方法。
考慮到代碼的重用性,我們的所有測試用例類都有一個共同的父類,也就是 TestCase
,它也是 XCTestCase
的子類,所有的測試類都是我們的 TestCase
類的子類。
然后我們把一些公用的輔助方法放在 TestCase
類中,并且加了一些屬性作為每個測試的預置屬性。
因為測試用例僅僅只是一個以test
為開頭的方法,所以典型的測試用例方法看起來就像這樣:
- (void)testThatItDoesURLEncoding
{
// test code
}
在我們的所有測試用例中都是以 “testThatIt” 為開頭。而另外一個用的比較多的命名方式是 “test + 要測試的方法和類名”,比如像 testHTTPRequest
。這些被測試的類和方法需要在測試用例中顯而易見。
“testThatIt” 這類命名方式將重點轉(zhuǎn)移到期望的結果上,但是大多數(shù)情況下,這很難讓我們一眼就能理解這個測試用例的意思。
這里每一個測試用例類都對應一個產(chǎn)品代碼類,而且測試用例類的名字是根據(jù)被測試代碼的名字決定的,比如,HTTPRequest
和 HTTPRequestTests
。如果一個類變得比較大的話,我們還可以使用 category 來將它們按主題分類。
比如我們想要禁止一個測試用例,我們只需要在方法名字前加 DISABLED
:
- (void)DISABLED_testThatItDoesURLEncoding
我們很容易就能找到這個方法,并且因為這個方法不再是以 test 為開頭,所以 XCTest 在運行時也會跳過這個測試用例。
我們可以根據(jù) Given-When-Then
模式來組織我們的測試用例,將測試用例拆分成三個部分。
在 given 部分里,通過創(chuàng)建模型對象或?qū)⒈粶y試的系統(tǒng)設置到指定的狀態(tài),來設定測試環(huán)境。when 這部分包含了我們要測試的代碼。在大部分情況,這里只有一個方法調(diào)用。在 then 這部分中 ,我們需要檢查我們行為的結果:是否得到了我們期望的結果?對象是否有改變?這部分主要包括一些斷言。
一個簡單的測試用例看起來是這個樣子的:
- (void)testThatItDoesURLEncoding
{
// given
NSString *searchQuery = @"$&?@";
HTTPRequest *request = [HTTPRequest requestWithURL:@"/search?q=%@", searchQuery];
// when
NSString *encodedURL = request.URL;
// then
XCTAssertEqualObjects(encodedURL, @"/search?q=%24%26%3F%40");
}
這種簡單的模式使我們能夠更容易地書寫和理解這些測試用例,因為它們都遵循了同樣的模式。為了更快地瀏覽,我們甚至會在每個部分的代碼上寫上 “given”,“when”,“then” 的注釋。通過這種方式,這個方法就能很快被理解。
隨著時間的流逝,我們注意到在我們的測試用例中有越來越多的重復代碼,比如等待異步才能完成,或者設置一個內(nèi)存中的 Core Data 堆棧等操作。為了避免代碼重復,我們開始整理所有有用的代碼片段,并將它們加入到一個公共類中,為所有的測試用例服務。
結果證明這個公共類非常實用。這個測試基礎類能夠運行自己的 -setUp
和 -tearDown
方法來配置環(huán)境。我們大部分情況用它來初始化測試用的 Core Data 棧,來重新設置我們的具有確定性的 NSUUID
(這是那些可以讓調(diào)試簡單得多的一些東西中的一個),并且設置一些后臺的魔法來簡化異步測試。
另外一個我們最近開始用的模式也很有用,就是在 XCTestCase
類中直接實現(xiàn)委托協(xié)議。通過這個方式,我們不用必須笨拙地 mock 這個 delegate。相反的,我們可以相當直接地與被測試的類互動。
我們使用的 mock 框架是 OCMock。就像在這篇關于 mock 的文章中描述的那樣,mock 是一個在方法調(diào)用時返回標準答案的對象。
我們用 mock 來管理一個對象的所有依賴項。通過這個方式,我們可以測試這個類在隔離情況下的行為。但是這里有個明顯的缺點,那就是當我們修改了一個類后,其他依賴于這個類的類的單元測試不能自動失敗。但是關于這一點我們可以通過集成測試來補救,因為它可以測試所有的類。
我們不應該‘過度mock’,也就是說,去 mock 除了被測試的對象的其他對象這樣的習慣是要盡量避免的。當我們剛開始的時候,我們經(jīng)常會這樣做,我們甚至會 mock 那些簡單到可以作為方法參數(shù)的對象?,F(xiàn)在我們使用了不少真實的對象,而不是 mock 它們。
作為我們所有測試類的公共父類的一部分,我們還加入了這個方法
- (void)verifyMockLater:(id)mock;
它可以保證這個 mock 會在這個方法結束的時候被驗證,這樣使用 mock 就會更加方便。我們可以在創(chuàng)建一個 mock 的時候就指定這個 mock 應該被驗證:
- (void)testThatItRollsBackWhenSaveFails;
{
// given
id contextMock = [OCMockObject partialMockForObject:self.uiMOC];
[self verifyMockLater:contextMock];
NSError *error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSManagedObjectValidationError userInfo:nil];
[(NSManagedObjectContext *)[[contextMock stub] andReturnValue:@NO] save:[OCMArg setTo:error]];
// expect
[[contextMock expect] rollback];
// when
[ZMUser insertNewObjectInManagedObjectContext:self.uiMOC];
[self.uiMOC saveOrRollback];
}
無狀態(tài)性的代碼在過去幾年中一直被提起。但是在現(xiàn)今,我們的 app 還是需要狀態(tài)。如果沒有狀態(tài),大部分 app 就會變得沒有意義。但是狀態(tài)的管理又很容易引起很多 bug,因為管理狀態(tài)非常復雜。
我們通過隔離這些狀態(tài)來使我們的代碼更好的運行。一些類中包含狀態(tài),而大部分則是無狀態(tài)的。通過這樣的方式之后,不僅是代碼變得更加簡單,測試用例也是如此。
比如說,我們有一個叫 EventSync
的類,它是負責把本地變化發(fā)送到服務器。所以它需要跟蹤哪些本地對象發(fā)生變化需要上傳到服務器,還有哪些本地變化現(xiàn)在正在被上傳到服務器。我們一次需要發(fā)送多個變化,但是我們不想發(fā)送重復的變化。
我們也有跟蹤對象之間的依賴關系。當 A 和 B 有依賴關系,并且 B 有本地變化,那么我們在發(fā)送 A 的本地變化之前,需要先等待 B 的本地變化發(fā)送完畢。
我們有一個 UserSyncStrategy
類,它有一個 -nextRequest
方法可以生成下一次請求。這個請求會將本地改變發(fā)送到服務器。雖然這個類本身是無狀態(tài)的。更確切地說,所有它的狀態(tài)都被封裝在一個叫 UpstreamObjcetSync
的類中,這個類負責跟蹤那些有本地變化的用戶對象,還有那些我們正在運行的請求。除了這個類之外其他東西都是沒有狀態(tài)的。
通過這個方式,我們可以很容易得到測試 UpstremObjectSync
的集合。它們檢查這個類是否正確地管理狀態(tài)。對于 UserSyncStrategy
來說,當我們在 mock UpstremObjectSync
的時候,就不用再擔心 UserSyncStrategy
本身的狀態(tài)了。這大大減少了測試的復雜度,更進一步,因為我們正在同步很多不同類型的對象,我們那些不同的類都是無狀態(tài)的,并且可以重用 UpstreamObjectSync
類,這使代碼簡單了很多。
我們的代碼非常依賴于 Core Data。因為我們需要我們的測試是相互隔離的,這樣我們就必須為每個測試用例創(chuàng)建一個干凈的 Core Data 棧,然后再銷毀它。我們需要確保在這個測試用例到下個測試用例的過程中沒有重復使用同一個 Core Data 存儲。
我們的所有代碼都是以兩個 managed object context 為中心:一個是用戶界面時要使用的,它需要放在主隊列上,而另一個是我們同步時要使用的,它被放在自己的私有隊列上。
我們不想在每個需要 managed object context 的測試中都去重復創(chuàng)建它們。所以我們在共享的 TestCase
父類的 -setUp
方法中加入了創(chuàng)建兩個 managed object context 的方法。這使每個獨立的測試用例更易讀。
一個測試用例需要 managed object context 時可以很方便地調(diào)用 self.managedObjectContext
或者 self.syncManagedObjectContext
,就像這樣:
- (void)testThatItDoesNotCrashWithInvalidFields
{
// given
NSDictionary *payload = // expected JSON response
@{
@"status": @"foo",
@"from": @"eeeee",
@"to": @44,
@"last_update": @[],
};
// when
ZMConnection *connection = [ZMConnection connectionFromTransportData:payload
managedObjectContext:self.managedObjectContext];
// then
XCTAssertNil(connection);
}
我們使用 NSMainQueueConcurrencyType
和 NSPrivateQueueConcurrencyType
來保持代碼的一致性。但是我們在 -performBlock:
之上實現(xiàn)了我們自己的 -performGroupedBlock:
來解決隔離的問題。關于這一點,在下面關于測試異步代碼這節(jié)中會講到。
在我們的代碼中有兩個context。在產(chǎn)品中,我們非常依賴于通過 -mergeChangesFromContextDidSaveNotification:
方法將一個 context 合并到另一個 context 中。同時,每個 context 使用一個單獨的 persistent store coordinator。這樣兩個 context 能以最小的資源沖突來訪問同一個 SQLite。
但是對于測試來說,我們必須改變這一點,我們想使用一個內(nèi)存上的存儲空間。
使用磁盤上的 SQLite 空間對于測試來說并不管用,因為在從磁盤中刪除存儲時會產(chǎn)生競態(tài)條件。它會打破測試用例之間相互隔離的局面。而且使用內(nèi)存空間能更加快速,這有利于測試。
我們使用工廠方法來創(chuàng)建我們的 NSManagedObjectContext
實例?;A測試類略微地改變了工廠方法的行為,來實現(xiàn)所有的 context 能夠公用同樣的 NSPersistentStoreCoordinator
。在每個測試的結束時,我們都要銷毀公用的 persistent store coordinator 來確保下個測試用例能夠使用新的 NSPersistentStoreCoordinator
和新的存儲。
測試異步代碼充滿了技巧性。大多數(shù)測試框架都有提供一些針對測試異步代碼的基礎輔助方法。
假設我們有一個關于 NSString
的異步消息:
- (void)appendString:(NSString *)other resultHandler:(void(^)(NSString *result))handler;
使用 XCTest,我們可以這樣測試它:
- (void)testThatItAppendsAString;
{
NSString *s1 = @"Foo";
XCTestExpectation *expectation = [self expectationWithDescription:@"Handler called"];
[s1 appendString:@"Bar" resultHandler:^(NSString *result){
[expectation fulfill];
XCTAssertEqualObjects(result, @"FooBar");
}];
[self waitForExpectationsWithTimeout:0.1 handler::nil];
}
大部分的測試框架都有類似這樣的東西。
但是異步代碼的測試的主要問題是隔離。隔離在英語中是 Isolation,也就是在這篇關于糟糕的測試的文章中被提到過的 FIRST 的字母 "I"。
測試異步代碼的時候,我們很難確定在下一個測試什么時候開始,因為我們不知道被測試的代碼是否在所有的線程或隊列中都已經(jīng)結束運行。
我發(fā)現(xiàn)對于這樣的問題的最好解決方法就是堅持使用 group,也就是dispatch_group_t
。
我們的一些類中需要在內(nèi)部使用 dispatch_queue_t
,一些則在 NSManagedObjectContext
的私有隊列中使用 block 隊列。
在我們的 -tearDown
方法中,我們需要等所有的異步工作結束。為了實現(xiàn)這樣的方式,我們必須做好幾件事情,就像下面提到的。
我們的測試類中有一個這樣的 property:
@property (nonatomic) dispatch_group_t;
我們在我們的公共父類中定義并且設置它。
接下來,我們可以將這個組放入到那些使用 dispatch_queue
或類似的東西的類中,比如,我們始終使用 dispatch_group_async()
來替換 dispatch_async()
。
因為我們非常依賴于 CoreData,所以我們?yōu)?NSManagedObjectContext
的調(diào)用增加一個方法:
- (void)performGroupedBlock:(dispatch_block_t)block ZM_NON_NULL(1);
{
dispatch_group_enter(self.dispatchGroup);
[self performBlock:^{
block();
dispatch_group_leave(self.dispatchGroup);
}];
}
并且為所有 managed object contexts 添加一個名為 dispatchGroup
的property。然后我們在所有的代碼中僅僅使用 -performGroupedBlock:
就可以了。
這樣我們就可以在 tearDown
方法中加入等待所有異步工作結束的代碼了:
- (void)tearDown
{
[self waitForGroup];
[super tearDown];
}
- (void)waitForGroup;
{
__block BOOL didComplete = NO;
dispatch_group_notify(self.requestGroup, dispatch_get_main_queue(), ^{
didComplete = YES;
});
NSDate *end = [NSDate dateWithTimeIntervalSinceNow:timeout];
while (! didComplete) {
NSTimeInterval const interval = 0.002;
if (! [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:interval]]) {
[NSThread sleepForTimeInterval:interval];
}
}
}
這是可行的,因為 -tearDown
是在 main loop 中被調(diào)用的。我們在 main loop 調(diào)用它就可以確保任意會進入主隊列中的代碼都被運行過。如果這個組永遠不空的話,上面這個代碼將會被掛起。在這個情況下,我們稍微調(diào)整了一下代碼,這樣我們就擁有了一個超時機制。
實現(xiàn)這個方法之后,我們的很多其他的測試用例也變得簡單很多。在這里,我們創(chuàng)建了一個 WaitForAllGroupsToBeEmpty()
輔助方法,我們可以這樣使用它:
- (void)testThatItDoesNotAskForNextRequestIfThereAreNoChangesWithinASave
{
// expect
[[self.transportSession reject] attemptToEnqueueSyncRequestWithGenerator:OCMOCK_ANY];
[[self.syncStrategy reject] processSaveWithInsertedObjects:OCMOCK_ANY updateObjects:OCMOCK_ANY];
[self verifyMockLater:self.transportSession];
// when
NSError *error;
XCTAssertTrue([self.testMOC save:&error]);
WaitForAllGroupsToBeEmpty(0.1);
}
最后一行代碼是等待所有的異步任務都執(zhí)行完,比如,這個測試用例確保了在那些異步的 block 又插入了額外的異步任務的時候,它們都會被執(zhí)行完畢,并且都沒有觸發(fā) rejected 相關的方法。
我們用一個簡單的宏來實現(xiàn)它
#define WaitForAllGroupsToBeEmpty(timeout) \
do { \
if (! [self waitForGroupToBeEmptyWithTimeout:timeout]) { \
XCTFail(@"Timed out waiting for groups to empty."); \
} \
} while (0)
在這里,作為替換,可以調(diào)用測試的公共父類中的一個方法:
- (BOOL)waitForGroupToBeEmptyWithTimeout:(NSTimeInterval)timeout;
{
NSDate * const end = [[NSDate date] dateByAddingTimeInterval:timeout];
__block BOOL didComplete = NO;
dispatch_group_notify(self.requestGroup, dispatch_get_main_queue(), ^{
didComplete = YES;
});
while ((! didComplete) && (0. < [end timeIntervalSinceNow])) {
if (! [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.002]]) {
[NSThread sleepForTimeInterval:0.002];
}
}
return didComplete;
}
在本節(jié)的開始,我們提到了
XCTestExpectation *expectation = [self expectationWithDescription:@"Handler called"];
和
[self waitForExpectationsWithTimeout:0.1 handler::nil];
是異步測試中的一些基本構建塊。
XCTest 在對于使用 NSNotification
和 KVO 上的情況提供了一些便利的方法,這些方法都是建立在這些構建塊的基礎上的。
但是很多時候,我們發(fā)現(xiàn)自己會在很多地方使用相同模式的代碼,比如,如果我們用異步地方式去檢驗 NSManagedObjectContext 對象被保存,我們可能會寫出如下代碼:
// expect
[self expectationForNotification:NSManagedObjectContextDidSaveNotification
object:self.syncManagedObjectContext
handler:nil];
我們可以抽象出一個公共的方法來簡化這個代碼
- (XCTestExpectation *)expectationForSaveOfContext:(NSManagedObjectContext *)moc;
{
return [self expectationForNotification:NSManagedObjectContextDidSaveNotification
object:moc
handler:nil];
}
然后再在測試用例中這樣使用它:
// expect
[self expectationForSaveOfContext:self.syncManagedObjectContext];
這樣更易讀。根據(jù)這種模式,我們也可以給其他情況也添加一些自定義的方法。
在測試時,一個很重要的問題就是如何測試應用與服務端之間的交互。最理想的解決方案是快速的以真實服務器為基礎建一個本地副本,給它提供一些假數(shù)據(jù),然后通過 http 對它直接運行測試用例。
實際上,我們就是使用的這種方案。它為我們提供了一個非常真實的測試配置。但這有個不好的方面,就是這種方案運行速度非常慢。在每次測試之間,清理服務器數(shù)據(jù)庫的速度就很慢。我們有 1,000 個測試用例,就算現(xiàn)在其中只有 30 個測試用例需要依賴真實的服務器,如果我們要清理數(shù)據(jù)庫,并且提供一個 “干凈” 的服務器實例就需要畫 5 秒鐘的時間,那么我們的測試過程中有 2.5 分鐘的時間是在等待清理。我們還有在服務器的 API 可用之前對它進行測試的需求,我們需要其他的解決方案。
替代的解決方案就是 ‘偽裝服務器’。從一開始,我們把所有和服務器交互的代碼全部都整合在 TransportSession
這個類中,這個類很接近于 NSURLSession
,不同的是它也可以處理 JSON 轉(zhuǎn)換。
我們有一系列的測試用例是使用我們提供給 UI 層的 API,并且所有的這些和服務器的交互都被整合到 TransportSession
類的一個偽裝的 實現(xiàn)中。這個傳輸會話同時模仿一個真實的 TransportSession
行為,以及一個服務器的行為。這個偽裝的會話實現(xiàn)了整個 TransportSession
協(xié)議,并且也提供了一些允許我們改變其狀態(tài)的方法。
相比在每個測試用例中使用 OCMock 來模擬服務器,使用一個自定義的類有很多優(yōu)勢。我們可以創(chuàng)建比使用 mock 更復雜的場景。我們可以模擬一些在真實服務器上很難觸發(fā)的邊緣情況。
并且,這個偽裝的服務器也有對其自身的測試用例,所以它的返回結果也是更精確。如果我們想改變服務器的反應到一個請求上去,我們只需要在一個地方改動即可。這使我們所有依賴于偽裝服務器的的測試用例更穩(wěn)定,這樣也能更容易發(fā)現(xiàn)代碼中和新的行為配合不好的地方。
FakeTransportSession
的實現(xiàn)很簡單。使用一個 HTTPRequest
對象來封裝請求相關的 URL、method 和其他一些可選的參數(shù)。FakeTransportSession
把所有的請求映射為內(nèi)部的方法,這些內(nèi)部方法產(chǎn)生相應的響應。它甚至有已擁有內(nèi)存空間的 Core Data 棧來跟蹤相應的對象。使用這種方式,一個 GET 請求可以返回一個之前使用 PUT 請求添加的資源。
所有的這些聽起來需要很多的時間投入。但是,這個偽裝的服務器實際上是非常簡單的:因為它不是一個真正的服務器,并且我們削減了大量的細節(jié)。這個偽裝的服務器只能夠為一個客戶端提供服務,并且我們也不需要擔心性能和擴展性。我們也不需要一次實現(xiàn)所有的功能,我們只需要實現(xiàn)在開發(fā)和測試中所需要的功能即可。
這里還有一件事情對我們很有利:在我們開始做這件事時,我們服務器的 API 已經(jīng)非常穩(wěn)定而且有良好的定義。
使用 Xcode Test 框架,我們需要使用 XCTAssert 宏來做實際的檢查:
XCTAssertNil(request1);
XCTAssertNotNil(request2);
XCTAssertEqualObjects(request2.path, @"/assets");
在蘋果的"編寫測試類和方法"這篇文章里,有一個全面的按照類別排列的斷言列表。
但是我們發(fā)現(xiàn)自己經(jīng)常使用一些特定情況的斷言,比如:
XCTAssertTrue([string isKindOfClass:[NSString class]] && ([[NSUUID alloc] initWithUUIDString:string] != nil),
@"'%@' is not a valid UUID string", string);
這么寫非常的啰嗦,難以閱讀。并且我們也不喜歡重復代碼。我們通過編寫自己的斷言宏來解決這個問題:
#define AssertIsValidUUIDString(a1) \
do { \
NSUUID *_u = ([a1 isKindOfClass:[NSString class]] ? [[NSUUID alloc] initWithUUIDString:(a1)] : nil); \
if (_u == nil) { \
XCTFail(@"'%@' is not a valid UUID string", a1); \
} \
} while (0)
在我們的測試用例中,我們只需要這樣使用它即可:
AssertIsValidUUIDString(string);
這種方式也讓代碼更具有可讀性。
我們都知道,使用 C 的預處理宏 就是在和野獸跳舞。
有很多事情是無法避免的,我們只能是做到如何減輕這種痛苦。我們需要讓測試框架知道這個斷言是在哪個文件的哪行代碼失敗的。XCTFail()
本身就是一個宏,而且它還依賴于 __FILE__
and __LINE__
。
對于更復雜的斷言和檢查,我們實現(xiàn)了一個簡單的輔助類 FailureRecorder
:
@interface FailureRecorder : NSObject
- (instancetype)initWithTestCase:(XCTestCase *)testCase filePath:(char const *)filePath lineNumber:(NSUInteger)lineNumber;
@property (nonatomic, readonly) XCTestCase *testCase;
@property (nonatomic, readonly, copy) NSString *filePath;
@property (nonatomic, readonly) NSUInteger lineNumber;
- (void)recordFailure:(NSString *)format, ... NS_FORMAT_FUNCTION(1,2);
@end
#define NewFailureRecorder() \
[[FailureRecorder alloc] initWithTestCase:self filePath:__FILE__ lineNumber:__LINE__]
在我們的代碼中,我們有一些地方我們想檢查兩個字典是不是相等:使用 XCTAssertEqualObjects()
可以做到,但是當不相等時,它的輸出卻不是很有用。
我們想這樣使用它
NSDictionary *payload = @{@"a": @2, @"b": @2};
NSDictionary *expected = @{@"a": @2, @"b": @5};
AssertEqualDictionaries(payload, expected);
檢查到不相等時,就輸出下面的結果
Value for 'b' in 'payload' does not match 'expected'. 2 == 5
所以我們創(chuàng)建了一個的宏
#define AssertEqualDictionaries(d1, d2) \
do { \
[self assertDictionary:d1 isEqualToDictionary:d2 name1:#d1 name2:#d2 failureRecorder:NewFailureRecorder()]; \
} while (0)
這個宏中調(diào)用了下面的方法
- (void)assertDictionary:(NSDictionary *)d1 isEqualToDictionary:(NSDictionary *)d2 name1:(char const *)name1 name2:(char const *)name2 failureRecorder:(FailureRecorder *)failureRecorder;
{
NSSet *keys1 = [NSSet setWithArray:d1.allKeys];
NSSet *keys2 = [NSSet setWithArray:d2.allKeys];
if (! [keys1 isEqualToSet:keys2]) {
XCTFail(@"Keys don't match for %s and %s", name1, name2);
NSMutableSet *missingKeys = [keys1 mutableCopy];
[missingKeys minusSet:keys2];
if (0 < missingKeys.count) {
[failureRecorder recordFailure:@"%s is missing keys: '%@'",
name1, [[missingKeys allObjects] componentsJoinedByString:@"', '"]];
}
NSMutableSet *additionalKeys = [keys2 mutableCopy];
[additionalKeys minusSet:keys1];
if (0 < additionalKeys.count) {
[failureRecorder recordFailure:@"%s has additional keys: '%@'",
name1, [[additionalKeys allObjects] componentsJoinedByString:@"', '"]];
}
}
for (id key in keys1) {
if (! [d1[key] isEqual:d2[key]]) {
[failureRecorder recordFailure:@"Value for '%@' in '%s' does not match '%s'. %@ == %@",
key, name1, name2, d1[key], d2[key]];
}
}
}
這里的技巧是,FailureRecorder
捕獲了 __FILE__
,__LINE__
和測試用例。在 -recordFailure:
方法內(nèi)部,它簡單地把字符串傳遞給測試用例:
- (void)recordFailure:(NSString *)format, ...;
{
va_list ap;
va_start(ap, format);
NSString *d = [[NSString alloc] initWithFormat:format arguments:ap];
va_end(ap);
[self.testCase recordFailureWithDescription:d inFile:self.filePath atLine:self.lineNumber expected:YES];
}
XCTest 最好的優(yōu)點就是它可以和 Xcode IDE 集成的非常好。使用 Xode 6 和 Xcode 6 Server,這方面的優(yōu)點更被加強了。這種緊密集成是非常有用的,并且能提高我們的效率。
當運行一個單一的測試用例或者在一個測試類中運行一系列測試用例時,點擊左邊欄上、靠近行數(shù)的小菱形按鈕,使我們可以運行特定的一個或者一系列測試用例:
http://wiki.jikexueyuan.com/project/objc/images/15-1.png" alt="" />
如果測試失敗,它會變成紅色:
http://wiki.jikexueyuan.com/project/objc/images/15-2.png" alt="" />
如果測試通過,它會變成綠色:
http://wiki.jikexueyuan.com/project/objc/images/15-3.png" alt="" />
我們最喜歡的一個鍵盤快捷鍵是 ^??G,它可以再一次的運行之前運行的最后一個或者最后一系列測試用例。當點擊了邊欄上的小菱形按鈕后,我們可以改變測試代碼,并且簡單的再去運行它們而不需要我們的手離開鍵盤。當調(diào)試測試用例時,這是非常有用的。
在 Xcode 左側的導航欄中有一列測試導航,這里是按照所屬類分組展示的測試用例:
http://wiki.jikexueyuan.com/project/objc/images/15-4.png" alt="" />
也可以從這里開始運行某一個單一的測試用例或者是某一組測試用例。更有用的是,我們可以使用導航欄底部的第三個小圖標來過濾所有失敗的測試用例。
http://wiki.jikexueyuan.com/project/objc/images/15-5.png" alt="" />
OS X Server 有一個叫做 Xcode Server 的特性,它是一個基于 Xcode 的持續(xù)集成服務器,我們已經(jīng)正在使用它了。
只要有新的提交,我們的 Xcode Server 就會自動從 Github 上 check out 我們的工程。我們配置它,讓它運行靜態(tài)分析,在 iPod touch 和一些 iOS 模擬器上運行所有的測試用例,并且最后自動打包成 Xcode archive 以供下載。
在 Xcode 6 中,這些 Xcode Server 的特性得到更好的發(fā)揮,即使是對復雜的工程。
我們有一個運行在 release 分支上的 Xcode Server 的自定義觸發(fā)器。這個觸發(fā)器腳本把生成好的 Xcode archive 上傳到文件服務器上。這樣一來,我們就有了基于版本控制的存檔。 UI 小組就可以從文件服務器上下載預編譯好的框架的指定版本。
如果你熟悉行為驅(qū)動開發(fā),你會發(fā)現(xiàn)我們的命名風格在很大程度上受這種測試方式的影響。之前,我們中有些人使用過 Kiwi 作為測試庫,所以很自然會集中在一個方法或者一個類的行為上。但是,這是不是意味著 XCTest 可以取代 BDD 庫呢?答案是:并不能完全取代。
XCTest 的優(yōu)勢和缺點都是由于它太簡單了。你只需要創(chuàng)建一個類,使用 “test” 作為測試方法名的前綴,只需要這樣就可以了,不需要再做其他的。和 Xcode 很好的集成性也是 XCTest 獲得青睞的原因。你可以點擊邊欄上的小菱形按鈕來運行測試用例,你也可以很容易的查看所有失敗的測試用例,也可以在測試用例列表中點擊某一行而快速的跳轉(zhuǎn)到某一個測試用例。
不幸的是,這已經(jīng)是 XCTest 的全部優(yōu)點了。在開發(fā)和測試中,使用 XCTest 時我們沒有碰到任何的障礙,但是經(jīng)常會想如果它能更方便一些就好了。XCTest 類看起來就像普通的類,而一個 BDD 測試套件的結構和其嵌套的上下文是顯而易見的。并且這種為測試創(chuàng)建嵌套上下文的可能性也是最缺失的。嵌套的上下文允許我們在使獨立的測試相對簡單的情況下創(chuàng)建越來越具體的場景。當然,在 XCTest 中這也是可以的,比如在一些測試用例中調(diào)用自定義的 setup 方法,但這并不方便。
BDD 框架的附加功能的重要性是取決于項目的大小。我們的結論是,XCTest 對中小型的工程來說是一個很好的選擇,但是對于更大型的工程,就有必要參考一下像 Kiwi 或者 Specta 這樣的 BDD 框架。
XCTest 是不是正確的選擇呢?你必須根據(jù)手頭的項目來做判斷。我們選擇使用 XCTest 作為 KISS (Keep it simple, stupid) 的一部分,當然我們也有一份希望改進的愿望清單。盡管我們不得不做一些取舍,但是 XCTest 對我們來說是很好的選擇。對于其他的測試框架,這些取舍將會是另外一些事情。