簡介:
單元測試是軟體開發的一個重要方面。畢竟,單元測試可以幫你找到bug和崩潰原因,而程式崩潰是Apple在審查時拒絕app上架的首要原因。
單元測試不是萬能的,但Apple把它作為開發套件的一部分,不僅讓你創作的APP更穩定,而且提供了一致、有趣的使用者體驗,這些都是讓使用者給你五星評價的源泉!iOS7提供了一個升級的單元測試架構,讓你在Xcode中運行單元測試更為容易。當你完成這一章節,你將學會如何給現有app添加測試——並有可能培養出對編寫測試的熱愛!
/*
本文翻譯自《iOS7 by Tutorials》一書的第十一章“Unit Testing in Xcode 5”,想體會原文精髓的朋友請到Raywenderlich商店支援正版。
——————(部落格園、新浪微博)葛布林大帝
*/
目錄:
一、單元測試基礎
二、開始項目
三、下一步何去何從?
四、挑戰
附錄:XCTest斷言參考
一、單元測試基礎
在過去,Xcode引入了一個叫做OCUnit的開源單元測試架構。而在Xcode 5裡面,Apple發布了他們自己的的單元測試架構,叫做XCTest。
如果你已經熟悉OCUnit,別擔心,XCTest是一個建立在OCUnit之上並且十份相似的API。從OCUnit過渡到XCTest非常簡單,要做的僅僅是把STFail替換為XCTFail、STAssert替換為XCTAssert等等諸如此類。如果你已經熟悉這些基礎,可以直接跳到下一節。
1.高層次概述
單元測試有四個層級。從上到下,它們分別是:
- 測試套件(Test suite)
- 這是項目裡測試的全部集合。在Xcode裡,測試套件被設定為一個單獨的build target。
- 測試案例類(Test case classes)
- 正如你可能所期待的,在一個物件導向體系中,測試被整合到類裡。在你的app裡,每個測試類別通常對應一個單獨類。例如,DeveloperTests類應該對應Developer類的測試。
- 測試案例方法(Test case methods)
- 每個測試類別包含多種方法,用來測試類別的各種功能。就像一個方法或函數應該既精簡又實用,每個測試案例應該測試一個特定的結果——並且完全測試。
- 斷言(Assertions)
- 斷言檢查對應預期結果的具體條件。如果條件不符合預期結果, Xcode會報錯指出宣告失敗。例如,可以斷言你的Developer 類響應“writeKillerApp: message”;如果它沒有,宣告失敗,Xcode報錯。
理論很美好,但有時舉例會更容易闡述事物。用Empty Application 模板建立一個新項目,命名為EmptyApp。 Xcode模板會自動包含一個叫做EmptyAppTests的test target,添加到EmptyApp的 app target裡,如:
注意測試案例類包含了一個沒有關聯標頭檔的.m檔案,開啟EmptyAppTests.m看看第一個測試案例的原始碼。
測試方法必須以單詞test開始,以便test runner能找到它們。在你的樣本項目裡,測試類別包含了一個測試方法,叫做testExample。
setUp和tearDown方法就像守護在測試案例周圍的衛兵一樣。
把所有對象的程式設定代碼或重複性代碼放到setUp裡,使測試案例方法保持清爽、高效。
類似的,關閉檔案控制代碼或取消掛起網路請求等清理活動的方法應該放到tearDown裡。
Test runner 會依次調用setUp、testExample和tearDown方法。如果你申明了第二個測試方法testSecondExample,Test runner會依次調用setUp、testSecondExample,最後是tearDown方法。如果你有多個測試方法,setUp和tearDown會在一個測試環節調用多次——每經過一個測試案例方法調用一次!
這個故事的寓意是不要放任何處理太慢或處理頻繁的東西到setUp或tearDown方法裡——這會讓你運行測試套件時面臨漫長的等待!
2.建立你的第一個測試
testExample方法只有一個叫做XCTFail的語句,正如它名字裡暗示的:總是會失敗。這個語句不是非常有用,你可以寫一個比它更好的!刪除testExample方法,並添加如下方法:
- (void)test_addition_twoPlusTwo_isFour
{ XCTAssert(2 + 2 == 4, @"2 + 2 should be 4 but %d was returned instead", 2+2);}
測試案例的一個常用命名標準是:unitOfWork_stateUnderTest_expectedBehavior (工作單元_測試狀態_預期行為)。
在這個例子裡,被測試的工作單元是加法,測試狀態是2 + 2,預期行為是結果為4。
所有XCTest斷言都有首碼XCT。XCTAssert是可用於單元測試的簡單斷言,第一個參數是預評估為ture的運算式,當宣告失敗時,其後NSLog風格的參數會顯示一條訊息。
確保項目的當前target為iPhone模擬器,通過視窗頂部目錄的Product -> Test(Command-U)來運行測試,模擬器會啟動並執行測試套件。如果通知處於啟用狀態,你會看到下列確認訊息:
為了證實第一個單元測試成功,切換到Test Navigator,箭頭指出了它:
哈哈!翠綠色的小勾旁邊顯示出了你的單元測試。
你還可以看到邊框空白處菱形表徵圖旁的代碼,如下所示:
這些表徵圖展示關聯測試代碼的狀態:
@implementation旁的綠色小勾表示這個類測試通過,test_addition_twoPlusTwo_isFour旁的綠色小勾表示這個方法測試通過。
同時,這些表徵圖也是按鈕:
點擊@implementation旁的表徵圖將會運行這個類的所有測試,點擊其他測試方法旁的表徵圖則會運行該測試方法,試一試吧!
現在你已經對測試的概念和執行有了初步瞭解,是時候開始本章的樣本項目了——測試開始!
二、開始項目
本章的剩餘部分你將使用一個名為Reversi的黑白棋遊戲項目,規則:兩個玩家,分別代表白方和黑方,輪流在8x8棋盤上落子。通過包圍對方棋子來吃掉它,遊戲結束時棋子最多的為勝者。
如何建立這個遊戲,請看:http://www.raywenderlich.com/29228/how-to-develop-an-ipad-board-game-app-part-12
下載本文頁尾提供的樣本項目並運行,點擊螢幕下方的Vs Computer按鈕與電腦進行對戰,感受一下這個遊戲的介面和玩法。
你獲勝了嗎?或者被AI對手爆出翔?不管怎樣,你的工作不是整日玩遊戲——是時候添加一些有用的測試到項目裡了。
1.添加測試的支援
第一個需要單元測試的是GameBoard類。這個類囊括了8x8棋盤的基本邏輯,64個儲存格中的每個都有一個狀態——空、黑棋或白棋——並且GameBoard執行個體讓你能擷取並設定每一個方塊的狀態。
開啟GameBoard.h看一下裡面的方法,在開始為現有代碼編寫測試之前,弄清楚各方法的作用和實現是一個好主意。
在GameBoard.h,你會看到下列兩個方法:
// gets the state of the cell at the given location// raises an NSRangeException if the column or row are out of bounds- (BoardCellState) cellStateAtColumn:(NSInteger)column andRow:(NSInteger)row;// sets the state of the cell at the given location// raises an NSRangeException if the column or row are out of bounds- (void) setCellState:(BoardCellState)state forColumn:(NSInteger)column andRow:(NSInteger)row;
cellStateAtColumn:andRow: 和 setCellState:forColumn:andRow: 由你非常熟悉的getter/setter模式裡發展出來,你的第一個測試是執行如下動作:
- 初始化一個GameBoard執行個體
- 設定cell狀態
- 擷取cell狀態
- 從指定的cell裡擷取cell狀態
第一步是建立一個GameBoard測試類別,右擊ReversiGameTests分組,選擇 iOS\Cocoa Touch\Objective-C test case class 建立一個名為GameBoardTests的測試類別,繼承自XCTestCase。
確保你的新測試案例添加到ReversiGameTests target,如(這個步驟非常重要,如果沒添加到正確的target裡,你的測試不會運行):
開啟 GameBoardTests.m 並且刪除 testExample 方法,你不需要它。
然後在 GameBoardTests.m 頂部匯入標頭檔(這僅僅是讓你的測試類別能夠訪問GameBoard類):GameBoard.h
#import "GameBoard.h"
你需要為你的所有測試提供一個GameBoard 執行個體,建立一個執行個體變數會比在每個測試裡申明一個清爽得多。
在GameBoardTests.m 裡更新@interface 如下:
@interface GameBoardTests : XCTestCase
{
GameBoard *_board;}
現在你有了_board執行個體變數,可以開始測試了。
setUp 方法是第一次初始化_board的好地方,修改setUp如下:
- (void)setUp{ [super setUp]; _board = [[GameBoard alloc] init]; }
現在這個類的所有測試案例方法都能夠訪問初始化後的_board執行個體變數了。
2.第一個測試
這是你需要為首個測試案例添加的所有步驟,添加以下方法到GameBoardTests.m:
- (void)test_setAndGetCellState_setValidCell_cellStateChanged { [_board setCellState:BoardCellStateWhitePiece forColumn:4 andRow:5];
BoardCellState retrievedState = [_board cellStateAtColumn:4 andRow:5];
XCTAssertEqual(BoardCellStateWhitePiece, retrievedState, @"The cell should be white!");}
上面的代碼在(4,5)儲存格裡設定了一個白棋,並且立刻檢索了相同儲存格的狀態。XCTAssertEqual 斷言檢查它們是否相等,如果不相等,你會看到一個異常資訊,然後你將得知有一些東西需要檢查。
上面代碼的方法名遵循我之前提到的格式,通過這個方法名,你可以很容易看出它通過設定正確的儲存格位置來測試setter和getter方法,並期待儲存格狀態的改變。
如果你的測試工作是有計劃的,確保iPhone和iPad模擬器都測試,然後運行測試(Command-U)。
切換到Test Navigator,你會看到一個綠色小勾表示測試通過,如:
這看起來只是一個簡單的測試,但是它在調試錯誤裡提供了巨大的價值。
在內部, GAMEBOARD類使用一個簡單的二維數組來跟蹤8X8棋盤。但如果你曾經改變了代表向量或矩陣的數組,本次測試將作為迴歸測試,確保interface 的基礎仍在工作。
作為一個附帶的好處,為現有的類編寫測試可以大大有助於理解代碼是如何工作的。分析類的方法可以協助你辨別其功能,並為你編寫測試提供便利。
3.測試異常
按照設計的功能測試代碼有助於確保其正確性,但也使得你的app“早早失敗或高調失敗”——那些異常遊戲狀態或無效條件被調試器很快抓住。
GameBoard.h裡cellStateAtColumn:andRow: 和 setCellState:forColumn:andRow: 方法的注釋表明,如果行或列超出棋盤邊框,它們會彈出錯誤。看起來你已經找到更多的測試條件。
添加下列兩個方法:
- (void)test_setCellState_withInvalidCoords_exceptionThrown {XCTAssertThrowsSpecificNamed([_board setCellState:BoardCellStateBlackPieceforColumn:10andRow:7], NSException,NSRangeException,@"Out-of-bounds board set should raise an exception");}- (void)test_getCellState_withInvalidCoords_exceptionThrown {XCTAssertThrowsSpecificNamed([_board cellStateAtColumn:7 andRow:-10],NSException,NSRangeException,@"Out-of-bounds board access should raise an exception");}
上面的代碼裡,test_setCellState_withInvalidCoords_exceptionThrown: 試圖設定超出範圍的儲存格(10,7),同時test_setCellState_withInvalidCoords_exceptionThrown: 試圖擷取超出範圍的儲存格(7,-10)。再次的,方法名已指出在正測試不正確的座標,報出異常正在意料之中。
XCTAssertThrowsSpecificNamed 採用以下四點作為參數:
- 應該報出異常的運算式
- 排除的類
- 排除的名稱
- 測試失敗時顯示的訊息
點擊Command-U運行測試,你應該看到以下結果
這是什嗎?你希望用出色的代碼通過測試,但是兩個錯誤標記在Issue Navigator上。測試失敗資訊也會顯示在代碼上,如:
所有的測試失敗訊息為:
[GameBoardTests test_getCellState_withInvalidCoords_exceptionThrown] failed: (([_board cellStateAtColumn:7 andRow:-10]) throws <NSException, "NSRangeException">) failed: throwing <NSException, "NSGenericException", "row or column out of bounds"> - Out-of- bounds board access should raise an exception
如果你分解上面的訊息,你會看到你希望的行為是(throws <NSException, "NSRangeException">) ,而實際發生的是(throwing <NSException, "NSGenericException">) 。
在這個例子裡,你期待的是NSRangeException ,但接收到的卻是NSGenericException 。
看起來你已經做了一些研究!
樣本項目地址:http://pan.baidu.com/s/1o6x6zxg