TDD的iOS開發初步以及Kiwi使用入門
Kiwi
測試驅動開發(Test Driven Development,以下簡稱TDD)是保證代碼質量的不二法則,也是先進程序開發的共識。Apple一直致力于在iOS開發中集成更加方便和可用的測試,在Xcode 5中,新的IDE和SDK引入了XCTest來替代原來的SenTestingKit,并且取消了新建工程時的“包括單元測試”的可選項(同樣待遇的還有使用ARC的可選項)。新工程將自動包含測試的target,并且相關框架也搭建完畢,可以說測試終于擺脫了iOS開發中“二等公民”的地位,現在已經變得和產品代碼一樣重要了。我相信每個工程師在完成自己的業務代碼的同時,也有最基本的編寫和維護相應的測試代碼的義務,以保證自己的代碼能夠正確運行。更進一步,如果能夠使用TDD來進行開發,不僅能保證代碼運行的正確性,也有助于代碼結構的安排和思考,有助于自身的不斷提高。我在最開始進行開發時也曾對測試嗤之以鼻,但后來無數的慘痛教訓讓我明白那么多工程師癡迷于測試或者追求更完美的測試,是有其深刻含義的。如果您之前還沒有開始為您的代碼編寫測試,我強烈建議,從今天開始,從現在開始(也許做不到的話,也請從下一個項目開始),編寫測試,或者嘗試一下TDD的開發方式。
而Kiwi是一個iOS平臺十分好用的行為驅動開發(Behavior Driven Development,以下簡稱BDD)的測試框架,有著非常漂亮的語法,可以寫出結構性強,非常容易讀懂的測試。因為國內現在有關Kiwi的介紹比較少,加上在測試這塊很能很多工程師們并沒有特別留意,水平層次可能相差會很遠,因此在這一系列的兩篇博文中,我將從頭開始先簡單地介紹一些TDD的概念和思想,然后從XCTest的最簡單的例子開始,過渡到Kiwi的測試世界。在下一篇中我將繼續深入介紹一些Kiwi的其他稍高一些的特性,以期更多的開發者能夠接觸并使用Kiwi這個優秀的測試框架。
什么是TDD,為什么我們要TDD
測試驅動開發并不是一個很新鮮的概念了。軟件開發工程師們(當然包括你我)最開始學習程序編寫時,最喜歡干的事情就是編寫一段代碼,然后運行觀察結果是否正確。如果不對就返回代碼檢查錯誤,或者是加入斷點或者輸出跟蹤程序并找出錯誤,然后再次運行查看輸出是否與預想一致。如果輸出只是控制臺的一個簡單的數字或者字符那還好,但是如果輸出必須在點擊一系列按鈕之后才能在屏幕上顯示出來的東西呢?難道我們就只能一次一次地等待編譯部署,啟動程序然后操作UI,一直點到我們需要觀察的地方么?這種行為無疑是對美好生命和絢麗青春的巨大浪費。于是有一些已經浪費了無數時間的資深工程師們突然發現,原來我們可以在代碼中構建出一個類似的場景,然后在代碼中調用我們之前想檢查的代碼,并將運行的結果與我們的設想結果在程序中進行比較,如果一致,則說明了我們的代碼沒有問題,是按照預期工作的。比如我們想要實現一個加法函數add,輸入兩個數字,輸出它們相加后的結果。那么我們不妨設想我們真的擁有兩個數,比如3和5,根據人人會的十以內的加法知識,我們知道答案是8.于是我們在相加后與預測的8進行比較,如果相等,則說明我們的函數實現至少對于這個例子是沒有問題的,因此我們對“這個方法能正確工作”這一命題的信心就增加了。這個例子的偽碼如下:
//Product Code add(float num1, float num 2) {...} //Test code let a = 3; let b = 5; let c = a + b; if (c == 8) { // Yeah, it works! } else { //Something wrong! } |
當測試足夠全面和具有代表性的時候,我們便可以信心爆棚,拍著胸脯說,這段代碼沒問題。我們做出某些條件和假設,并以其為條件使用到被測試代碼中,并比較預期的結果和實際運行的結果是否相等,這就是軟件開發中測試的基本方式。

可以看到,VVStackTests是XCTestCase的子類,而XCTestCase正是XCTest測試框架中的測試用例類。XCTest在進行測試時將會尋找測試target中的所有XCTestCase子類,并運行其中以test開頭的所有實例方法。在這里,默認實現的-testExample將被執行,而在這個方法里,Xcode默認寫了一個XCTFail的斷言,來強制這個測試失敗,用以提醒我們測試還沒有實現。所謂斷言,就是判斷輸入的條件是否滿足。如果不滿足,則拋出錯誤并輸出預先規定的字符串作為提示。在這個Fail的斷言一定會失敗,并提示沒有實現該測試。另外,默認還有兩個方法-setUp和-tearDown,正如它們的注釋里所述,這兩個方法會分別在每個測試開始和結束的時候被調用。我們現在正要開始編寫我們的測試,所以先將原來的-testExample刪除掉。現在再使用?U來進行測試,應該可以順利通過了(因為我們已經沒有任何測試了)。
接下來讓我們想想要做什么吧。我們要實現一個簡單的棧數據結構,那么當然會有一個類來代表這種數據結構,在這個工程中我打算就叫它VVStack。按照常規,我們可以新建一個Cocoa Touch類,繼承NSObject并且開始實現了。但是別忘了,我們現在在TDD,我們需要先寫測試!那么首先測試的目標是什么呢?沒錯,是測試這個VVStack類是否存在,以及是否能夠初始化。有了這個目標,我們就可以動手開始編寫測試了。在文件開頭加上#import "VVStack.h",然后在VVStackTests.m的@end前面加上如下代碼:
- (void)testStackExist { XCTAssertNotNil([VVStack class], @"VVStack class should exist."); } - (void)testStackObjectCanBeCreated { VVStack *stack = [VVStack new]; XCTAssertNotNil(stack, @"VVStack object can be created."); } |
當然是不可能通過測試的,而且甚至連編譯都無法完成,因為我們現在根本沒有一個叫做VVStack的類。最簡單的讓測試通過的方法就是在產品代碼中添加VVStack類。新建一個Cocoa Touch的Objective-C class,取名VVStack,作為NSObject的子類。注意在添加的時候,應該只將其加入產品的target中:
添加類的時候注意選擇合適的target
由于VVStack是NSObject的子類,所以上面的兩個斷言應該都能通過。這時候再運行測試,成功變綠。接下來我們開始考慮這個類的功能:棧的話肯定需要能夠push,并且push后的棧頂元素應該就是剛才所push進去的元素。那么建立一個push方法的測試吧,在剛才添加的代碼之下繼續寫:
- (void)testPushANumberAndGetIt { VVStack *stack = [VVStack new]; [stack push:2.3]; double topNumber = [stack top]; XCTAssertEqual(topNumber, 2.3, @"VVStack should can be pushed and has that top value."); } |
因為我們還沒有實現-push:和-top方法,所以測試毫無疑問地失敗了(在ARC環境中直接無法編譯)。為了使測試立即通過我們首先需要在VVStack.h中聲明這兩個方法,然后在.m的實現文件中進行實現。令測試通過的最簡單的實現是一個空的push方法以及直接返回2.3這個數:
//VVStack.h @interface VVStack : NSObject - (void)push:(double)num; - (double)top; @end //VVStack.m @implementation VVStack - (void)push:(double)num { } - (double)top { return 2.3; } @end |
再次運行測試,我們順利回到了綠燈狀態。也許你很快就會說,這算哪門子實現啊,如果再增加一組測試例,比如push一個4.6,然后檢查top,不就失敗了么?我們難道不應該直接實現一個真正的合理的實現么?對此的回答是,在實際開發中,我們肯定不會以這樣的步伐來處理像例子中這樣類似的簡單問題,而是會直接跳過一些error-try的步驟,實現一個比較完整的方案。但是在更多的時候,我們所關心和需要實現的目標并不是這樣容易。特別是在對TDD還不熟悉的時候,我們有必要放慢節奏和動作,將整個開發理念進行充分實踐,這樣才有可能在之后更復雜的案例中正確使用。于是我們發揚不怕繁雜,精益求精的精神,在剛才的測試例上增加一個測試,回到VVStackTests.m中,在剛才的測試方法中加上:
- (void)testPushANumberAndGetIt { //... [stack push:4.6]; topNumber = [stack top]; XCTAssertEqual(topNumber, 4.6, @"Top value of VVStack should be the last num pushed into it"); } |
很好,這下子我們回到了紅燈狀態,這正是我們所期望的,現在是時候來考慮實現這個棧了。這個實現過于簡單,也有非常多的思路,其中一種是使用一個NSMutableArray來存儲數據,然后在top方法里返回最后加入的數據。修改VVStack.m,加入數組,更改實現:
//VVStack.m @interface VVStack() @property (nonatomic, strong) NSMutableArray *numbers; @end @implementation VVStack - (id)init { if (self = [super init]) { _numbers = [NSMutableArray new]; } return self; } - (void)push:(double)num { [self.numbers addObject:@(num)]; } - (double)top { return [[self.numbers lastObject] doubleValue]; } @end |
測試通過,注意到在-testStackObjectCanBeCreated和testPushANumberAndGetIt兩個測試中都生成了一個VVStack對象。在這個測試文件中基本每個測試都會需要初始化對象,因此我們可以考慮在測試文件中添加一個VVStack的實例成員,并將測試中的初始化代碼移到-setUp中,并在-tearDown中釋放。
接下來我們可以模仿繼續實現pop等棧的方法。鑒于篇幅這里不再繼續詳細實現,大家可以自己動手試試看。記住先實現測試,然后再實現產品代碼。一開始您可能會覺得這很無聊,效率低下,但是請記住這是起步練習不可缺少的一部分,而且在我們的例子中其實一切都是以“慢動作”在進行的。相信在經過實踐和使用后,您將會逐漸掌握自己的節奏和重點測試。關于使用XCTest到這里為止的代碼,可以在github上找到。Kiwi和BDD的測試思想
XCTest是基于OCUnit的傳統測試框架,在書寫性和可讀性上都不太好。在測試用例太多的時候,由于各個測試方法是割裂的,想在某個很長的測試文件中找到特定的某個測試并搞明白這個測試是在做什么并不是很容易的事情。所有的測試都是由斷言完成的,而很多時候斷言的意義并不是特別的明確,對于項目交付或者新的開發人員加入時,往往要花上很大成本來進行理解或者轉換。另外,每一個測試的描述都被寫在斷言之后,夾雜在代碼之中,難以尋找。使用XCTest測試另外一個問題是難以進行mock或者stub,而這在測試中是非常重要的一部分(關于mock測試的問題,我會在下一篇中繼續深入)。
行為驅動開發(BDD)正是為了解決上述問題而生的,作為第二代敏捷方法,BDD提倡的是通過將測試語句轉換為類似自然語言的描述,開發人員可以使用更符合大眾語言的習慣來書寫測試,這樣不論在項目交接/交付,或者之后自己修改時,都可以順利很多。如果說作為開發者的我們日常工作是寫代碼,那么BDD其實就是在講故事。一個典型的BDD的測試用例包活完整的三段式上下文,測試大多可以翻譯為Given..When..Then的格式,讀起來輕松愜意。BDD在其他語言中也已經有一些框架,包括最早的Java的JBehave和赫赫有名的Ruby的RSpec和Cucumber。而在objc社區中BDD框架也正在欣欣向榮地發展,得益于objc的語法本來就非常接近自然語言,再加上C語言宏的威力,我們是有可能寫出漂亮優美的測試的。在objc中,現在比較流行的BDD框架有cedar,specta和Kiwi。其中個人比較喜歡Kiwi,使用Kiwi寫出的測試看起來大概會是這個樣子的:
describe(@"Team", ^{ context(@"when newly created", ^{ it(@"should have a name", ^{ id team = [Team team]; [[team.name should] equal:@"Black Hawks"]; }); it(@"should have 11 players", ^{ id team = [Team team]; [[[team should] have:11] players]; }); }); }); |
我們很容易根據上下文將其提取為Given..When..Then的三段式自然語言
Given a team, when newly created, it should have a name, and should have 11 players
很簡單啊有木有!在這樣的語法下,是不是寫測試的興趣都被激發出來了呢。關于Kiwi的進一步語法和使用,我們稍后詳細展開。首先來看看如何在項目中添加Kiwi框架吧。
在項目中添加Kiwi
最簡單和最推薦的方法當然是CocoaPods,如果您對CocoaPods還比較陌生的話,推薦您花時間先看一看這篇CocoaPods的簡介。Xcode 5和XCTest環境下,我們需要在Podfile中添加類似下面的條目(記得將VVStackTests換成您自己的項目的測試target的名字):
target :VVStackTests, :exclusive => true do
pod 'Kiwi/XCTest'
end
之后pod install以后,打開生成的xcworkspace文件,Kiwi就已經處于可用狀態了。另外,為了我們在新建測試的時候能省點事兒,可以在官方repo里下載并運行安裝Kiwi的Xcode Template。如果您堅持不用CocoaPods,而想要自己進行配置Kiwi的話,可以參考這篇wiki。
行為描述(Specs)和期望(Expectations),Kiwi測試的基本結構
我們先來新建一個Kiwi測試吧。如果安裝了Kiwi的Template的話,在新建文件中選擇Kiwi/Kiwi Spec來建立一個Specs,取名為SimpleString,注意選擇目標target為我們的測試target,模板將會在新建的文件名字后面加上Spec后綴。傳統測試的文件名一般以Tests為后綴,表示這個文件中含有一組測試,而在Kiwi中,一個測試文件所包含的是一組對于行為的描述(Spec),因此習慣上使用需要測試的目標類來作為名字,并以Spec作為文件名后綴。在Xcode 5中建立測試時已經不會同時創建.h文件了,但是現在的模板中包含有對同名.h的引用,可以在創建后將其刪去。如果您沒有安裝Kiwi的Template的話,可以直接創建一個普通的Objective-C test case class,然后將內容替換為下面這樣:
#import <Kiwi/Kiwi.h>
SPEC_BEGIN(SimpleStringSpec)
describe(@"SimpleString", ^{
});
SPEC_END
你可能會覺得這不是objc代碼,甚至懷疑這些語法是否能夠編譯通過。其實SPEC_BEGIN和SPEC_END都是宏,它們定義了一個KWSpec的子類,并將其中的內容包裝在一個函數中(有興趣的朋友不妨點進去看看)。我們現在先添加一些描述和測試語句,并運行看看吧,將上面的代碼的SPEC_BEGIN和SPEC_END之間的內容替換為:
describe(@"SimpleString", ^{ context(@"when assigned to 'Hello world'", ^{ NSString *greeting = @"Hello world"; it(@"should exist", ^{ [[greeting shouldNot] beNil]; }); it(@"should equal to 'Hello world'", ^{ [[greeting should] equal:@"Hello world"]; }); }); }); |
describe描述需要測試的對象內容,也即我們三段式中的Given,context描述測試上下文,也就是這個測試在When來進行,最后it中的是測試的本體,描述了這個測試應該滿足的條件,三者共同構成了Kiwi測試中的行為描述。它們是可以nest的,也就是一個Spec文件中可以包含多個describe(雖然我們很少這么做,一個測試文件應該專注于測試一個類);一個describe可以包含多個context,來描述類在不同情景下的行為;一個context可以包含多個it的測試例。讓我們運行一下這個測試,觀察輸出:
VVStack[36517:70b] + 'SimpleString, when assigned to 'Hello world', should exist' [PASSED]
VVStack[36517:70b] + 'SimpleString, when assigned to 'Hello world', should equal to 'Hello world'' [PASSED]
可以看到,這三個關鍵字的描述將在測試時被依次打印出來,形成一個完整的行為描述。除了這三個之外,Kiwi還有一些其他的行為描述關鍵字,其中比較重要的包括
beforeAll(aBlock) - 當前scope內部的所有的其他block運行之前調用一次
afterAll(aBlock) - 當前scope內部的所有的其他block運行之后調用一次
beforeEach(aBlock) - 在scope內的每個it之前調用一次,對于context的配置代碼應該寫在這里
afterEach(aBlock) - 在scope內的每個it之后調用一次,用于清理測試后的代碼
specify(aBlock) - 可以在里面直接書寫不需要描述的測試
pending(aString, aBlock) - 只打印一條log信息,不做測試。這個語句會給出一條警告,可以作為一開始集中書寫行為描述時還未實現的測試的提示。
xit(aString, aBlock) - 和pending一樣,另一種寫法。因為在真正實現時測試時只需要將x刪掉就是it,但是pending語意更明確,因此還是推薦pending
可以看到,由于有context的存在,以及其可以嵌套的特性,測試的流程控制相比傳統測試可以更加精確。我們更容易把before和after的作用區域限制在合適的地方。
實際的測試寫在it里,是由一個一個的期望(Expectations)來進行描述的,期望相當于傳統測試中的斷言,要是運行的結果不能匹配期望,則測試失敗。在Kiwi中期望都由should或者shouldNot開頭,并緊接一個或多個判斷的的鏈式調用,大部分常見的是be或者haveSomeCondition的形式。在我們上面的例子中我們使用了should not be nil和should equal兩個期望來確保字符串賦值的行為正確。其他的期望語句非常豐富,并且都符合自然語言描述,所以并不需要太多介紹。在使用的時候不妨直接按照自己的想法來描述自己的期望,一般情況下在IDE的幫助下我們都能找到想要的結果。如果您想看看完整的期望語句的列表,可以參看文檔的這個頁面。另外,您還可以通過新建KWMatcher的子類,來簡單地自定義自己和項目所需要的期望語句。從這一點來看,Kiwi可以說是一個非常靈活并具有可擴展性的測試框架。
到此為止的代碼可以從這里找到。
Kiwi實際使用實例
最后我們來用Kiwi完整地實現VVStack類的測試和開發吧。首先重寫剛才XCTest的相關測試:新建一個VVStackSpec作為Kiwi版的測試用例,然后把describe換成下面的代碼:
describe(@"VVStack", ^{ context(@"when created", ^{ __block VVStack *stack = nil; beforeEach(^{ stack = [VVStack new]; }); afterEach(^{ stack = nil; }); it(@"should have the class VVStack", ^{ [[[VVStack class] shouldNot] beNil]; }); it(@"should exist", ^{ [[stack shouldNot] beNil]; }); it(@"should be able to push and get top", ^{ [stack push:2.3]; [[theValue([stack top]) should] equal:theValue(2.3)]; [stack push:4.6]; [[theValue([stack top]) should] equal:4.6 withDelta:0.001]; }); }); }); |
看到這里的您看這段測試應該不成問題。需要注意的有兩點:首先stack分別是在beforeEach和afterEach的block中的賦值的,因此我們需要在聲明時在其前面加上__block標志。其次,期望描述的should或者shouldNot是作用在對象上的宏,因此對于標量,我們需要先將其轉換為對象。Kiwi為我們提供了一個標量轉對象的語法糖,叫做theValue,在做精確比較的時候我們可以直接使用例子中直接與2.3做比較這樣的寫法來進行對比。但是如果測試涉及到運算的話,由于浮點數精度問題,我們一般使用帶有精度的比較期望來進行描述,即4.6例子中的equal:withDelta:(當然,這里只是為了demo,實際在這用和上面2.3一樣的方法就好了)。 接下來我們再為這個context添加一個測試例,用來測試初始狀況時棧是否為空。因為我們使用了一個Array來作為存儲容器,根據我們之前用過的equal方法,我們很容易想到下面這樣的測試代碼
it(@"should equal contains 0 element", ^{
[[theValue([stack.numbers count]) should] equal:theValue(0)];
});
這段測試在邏輯上沒有太大問題,但是有非常多值得改進的地方。首先如果我們需要將原來寫在Extension里的numbers暴露到頭文件中,這對于類的封裝是一種破壞,對于這個,一種常見的做法是只暴露一個-count方法,讓其返回numbers的元素個數,從而保證numbers的私有性。另外對于取值和轉換,其實theValue的存在在一定程度上是破壞了測試可讀性的,我們可以想辦法改善一下,比如對于0的來說,我們有beZero這樣的期望可以使用。簡單改寫以后,這個VVStack.h和這個測試可以變成這個樣子:
//VVStack.h //... - (NSUInteger)count; //... //VVStack.m //... - (NSUInteger)count { return [self.numbers count]; } //... it(@"should equal contains 0 element", ^{ [[theValue([stack count]) should] beZero]; }); |
更進一步地,對于一個collection來說,Kiwi有一些特殊處理,比如have和haveCountOf系列的期望。如果測試的對象實現了-count方法的話,我們就可以使用這一系列期望來寫出更好的測試語句。比如上面的測試還可以進一步寫成
it(@"should equal contains 0 element", ^{
[[stack should] haveCountOf:0];
});
在這種情況下,我們并沒有顯式地調用VVStack的-count方法,所以我們可以在頭文件中將其刪掉。但是我們需要保留這個方法的實現,因為測試時是需要這個方法的。如果測試對象不能響應count方法的話,如你所料,測試時會扔一個unrecognized selector的錯。Kiwi的內部實現是一個大量依賴了一個個行為Matcher和objc的消息轉發,對objcruntime特性比較熟悉,并想更深入的朋友不放可以看看Kiwi的源碼,寫得相當漂亮。
其實對于這個測試,我們還可以寫出更漂亮的版本,像這樣:
it(@"should equal contains 0 element", ^{
[[stack should] beEmpty];
});
好了。關于空棧這個情景下的測試感覺差不多了。我們繼續用TDD的思想來完善VVStack類吧。棧的話,我們當然需要能夠-pop,也就是說在(Given)給定一個棧時,(When)當棧中有元素的時候,(Then)我們可以pop它,并且得到棧頂元素。我們新建一個context,然后按照這個思路書寫行為描述(測試):
context(@"when new created and pushed 4.6", ^{ __block VVStack *stack = nil; beforeEach(^{ stack = [VVStack new]; [stack push:4.6]; }); afterEach(^{ stack = nil; }); it(@"can be poped and the value equals 4.6", ^{ [[theValue([stack pop]) should] equal:theValue(4.6)]; }); it(@"should contains 0 element after pop", ^{ [stack pop]; [[stack should] beEmpty]; }); }); |
完成了測試書寫后,我們開始按照設計填寫產品代碼。在VVStack.h中完成申明,并在.m中加入相應實現。
- (double)pop {
double result = [self top];
[self.numbers removeLastObject];
return result;
}
很簡單吧。而且因為有測試的保證,我們在提供像Stack這樣的基礎類時,就不需要等到或者在真實的環境中檢測了。因為在被別人使用之前,我們自己的測試代碼已經能夠保證它的正確性了。VVStack剩余的最后一個小問題是,在棧是空的時候,我們執行pop操作時應該給出一個錯誤,用以提示空棧無法pop。雖然在objc中異常并不常見,但是在這個情景下是拋異常的好時機,也符合一般C語言對于出空棧的行為。我們可以在之前的“when created”上下文中加入一個期望:
it(@"should raise a exception when pop", ^{
[[theBlock(^{
[stack pop];
}) should] raiseWithName:@"VVStackPopEmptyException"];
});
和theValue配合標量值類似,theBlock也是Kiwi中的一個轉換語法,用來將一段程序轉換為相應的matcher,使其可以被施加期望。這里我們期望空的Stack在被pop時拋出一個叫做”VVStackPopEmptyException”的異常。我們可以重構pop方法,在棧為空時給一個異常:
- (double)pop { if ([self count] == 0) { [NSException raise:@"VVStackPopEmptyException" format:@"Can not pop an empty stack."]; } double result = [self top]; [self.numbers removeLastObject]; return result; } |
進一步的Kiwi
VVStack的測試和實現就到這里吧,根據這套測試,您可以使用自己的實現來輕易地重構這個類,而不必擔心破壞它的公共接口的行為。如果需要添加新的功能或者修正已有bug的時候,我們也可以通過添加或者修改相應的測試,來確保正確性。我將會在下一篇博文中繼續介紹Kiwi,看看Kiwi在異步測試和mock/stub的使用和表現如何。Kiwi現在還在比較快速的發展中,官方repo的wiki上有一些不錯的資料和文檔,可以參考。VVStack的項目代碼可以在這個repo上找到,可以作為參考。
posted on 2014-02-25 10:34 順其自然EVO 閱讀(640) 評論(0) 編輯 收藏 所屬分類: 測試學習專欄