開始測試之旅并不是一件輕松的事,特別是在沒有人幫助的情況下。如果之前你曾經(jīng)做過類似的嘗試,然后你印象中會有那么一個時刻:“就是它,我等不及要開始測試。我聽聞 TDD 是多么有益,所以我現(xiàn)在必須開始使用它?!?/p>
于是你坐在電腦前,打開 IDE, 為你其中一個組件建立了第一個測試文件。
然后它就一片空白,也許你寫了一些基本功能的測試,但是你總覺得哪里不對。有個問題始終潛伏在腦海深處。這個問題在真正前行之前必須要回答。
我應(yīng)該測試些什么
要回答這個問題并沒有那么簡單,實際上。這是一個非常復(fù)雜的問題。值得慶幸的是你不是第一個有此疑問的人,也絕對不是最后一個。
但是你依然希望按照你的想法進行測試。所以你寫的測試僅僅調(diào)用了你的方法 (單元測試對不對?)。
-(void)testDownloadData;
像這樣的測試有一個根本的問題:它們不會告訴你應(yīng)該發(fā)生什么。它們也不會告訴你實際的預(yù)期是什么。它不清楚需求到底是什么。
此外,當(dāng)一個測試失敗,你必須深入代碼并且理解為什么失敗。這就需要大量額外不必要的認知負荷。在理想世界里,你不應(yīng)該需要僅僅為了弄明白哪里出錯了這種事情而花費如此大量的時間和精力。
這就是為什么會有行為驅(qū)動開發(fā) (BDD),它旨在解決具體問題,幫助開發(fā)人員確定應(yīng)該測試些什么。此外,它提供了一個 DSL(譯者注: Domain-specific language,域特定語言)鼓勵開發(fā)者弄清楚他們的需求,并且它引入了一個通用語言幫助你輕易理解測試的目的。
如此深刻的問題的答案卻驚人的簡單,但是它需要改變你的對測試套件的看法。BDD 的第一個單詞就表明了這一點,你不應(yīng)該關(guān)注于測試,而是應(yīng)該關(guān)注行為。這個看似毫無意義的變化提供了應(yīng)該測試什么的準確答案:你應(yīng)該測試行為。
但是什么是行為?好吧,為了回答這個問題,我們需要更技術(shù)一點。
讓我們思考你設(shè)計的 app 中的一個對象。它有一個接口定義了其方法和依賴關(guān)系。這些方法和依賴,聲明了你對象的約定。它們定義了如何與你應(yīng)用的其他部分交互,以及它的功能是什么。它們定義了對象的行為。
同時這也應(yīng)該是你的目標:測試你對象的行為方式。
在我們討論 BDD DSL 優(yōu)勢之前,讓我們首先過一遍它的基本原理,并看一個 Car
類的簡單測試套件應(yīng)該怎么寫:
SpecBegin(Car)
describe(@"Car", ^{
__block Car *car;
// Will be run before each enclosed it
beforeEach(^{
car = [Car new];
});
// Will be run after each enclosed it
afterEach(^{
car = nil;
});
// An actual test
it(@"should be red", ^{
expect(car.color).to.equal([UIColor redColor]);
});
describe(@"when it is started", ^{
beforeEach(^{
[car start];
});
it(@"should have engine running", ^{
expect(car.engine.running).to.beTruthy();
});
});
describe(@"move to", ^{
context(@"when the engine is running", ^{
beforeEach(^{
car.engine.running = YES;
[car moveTo:CGPointMake(42,0)];
});
it(@"should move to given position", ^{
expect(car.position).to.equal(CGPointMake(42, 0));
});
});
context(@"when the engine is not running", ^{
beforeEach(^{
car.engine.running = NO;
[car moveTo:CGPointMake(42,0)];
});
it(@"should not move to given position", ^{
expect(car.engine.running).to.beTruthy();
});
});
});
});
SpecEnd
SpecBegin
聲明了一個名為 CarSpec
測試類. SpecEnd
結(jié)束了類聲明。
describe
塊聲明了一組實例。
context
塊的行為類似于 describe
(語法糖)。
it
是一個單一的例子 (單一測試)。
beforeEach
是一個運行于所有同級塊和嵌套塊之前的塊。
可能你已經(jīng)注意到,幾乎在這種 DSL 中定義的所有組件由兩部分都組成:一個字符串值定義了什么被測試,以及一個包含了測試其本身或者更多組件的塊。這些字符串有兩個非常重要的功能。
首先,在 describe
塊內(nèi),這些字符串將聯(lián)系緊密的被測試的一部分特性的行為進行分組描述 (例如,移動一輛汽車)。因為你可以按意愿指定任意多的嵌套塊,你可以基于對象或者它們的依賴關(guān)系的上下文來編寫的不同的測試。
這就是正在發(fā)生在 move to
的 describe
塊里的事情:我們建立了兩個 contex
塊來提供 Car
內(nèi)基于不同狀態(tài)的不同期望 (發(fā)動機啟動或關(guān)閉)。這說明了 BDD DSL 鼓勵弄清楚對象在給定條件下應(yīng)該如何表現(xiàn)這一要求。
接下來,這些字符串創(chuàng)建了測試失敗時用來通知你的句子。例如,讓我們假設(shè)“引擎未啟動時進行移動”這一測試用例失敗了。我們將收到 "Car move to when engine is not running should not move to given position" 的錯誤信息。這些語句對我們理解失敗和預(yù)期的行為提供了非常大的幫助,重點是不需要閱讀任何實際代碼,因此它們減少了認知負荷。此外,它提供了一個標準語言來幫助你了解你團隊的每一個成員,即便他們技術(shù)略差。
記住你也可以在編寫不包含 BDD-style 語法的時候書寫有著明確需求和易于理解命名的測試 (例如 XCtest)。然而,BDD 已經(jīng)從頭建立了這些功能和語法,使得測試更加容易。
如果你希望學(xué)更多的 BDD 語法,你應(yīng)該看看 Specta guide for writing specs.
對于 iOS 或者 Mac 開發(fā)者,你可以從這些 BDD 框架之中選取其一:
當(dāng)涉及到語法,所有這些框架幾乎是相同的。它們之間的主要區(qū)別在于它們的可配置能力和綁定的組件。
Cedar 捆綁了匹配 (mathers)和置換 (doubles)。在這篇文章里,讓我們把置換就當(dāng)作 mocks 吧,雖然這么認為并不是非常準確 (你可以在這篇文章中 學(xué)習(xí)置換和 mocks 的區(qū)別)。
除了這些輔助工具,Ceder 還包含了額外的配置功能:集中測試。集中測試的意思是 Ceder 將只執(zhí)行一個測試或者一組測試。想要啟用集中測試,可以在 it
,describe
或者 context
塊的前面添加 f
,
同樣 Ceder 提供了反向配置能力:你可以添加 x
添加到測試中來關(guān)閉它。XCTest 有類似的配置能力,然而它們是通過操作 schemes 實現(xiàn) (或者手動點擊 "Run this test")。Cedar 配置更加更簡單快速。
Cedar 用了一點黑客技術(shù)才能與 XCTest 集成,如果 Apple 決定改變 XCTest 內(nèi)部實現(xiàn)的話,那么 Cedar 非常容易失效。然而從用戶角度來看, Cedar 工作起來就像集成 XCTest 一樣容易。
Kiwi 同樣捆綁了匹配模塊以及 stubs 和 mocks。與 Cedar 不同的是, Kiwi 緊緊與 XCTest 結(jié)合在一起,然而,它缺乏像 Cedar 一樣的可配置性功能。
Specta 用另一種途徑來達到測試工具的目的,因為它缺少匹配,也沒有 mocks 或者 stubs。它緊密地與 XCTest 結(jié)合在一起并且提供了近似 Cedar 的可配置性的能力。
正如前面提到過的,Cedar,Kiwi,以及 Specta 提供類似語法,我不能說其中一個框架要比其他所有都好;它們各有利弊。選擇 BDD 框架歸根結(jié)底來自個人偏好。
另外值得一提的是已經(jīng)有兩個 Swift 專用的 BDD 框架。
還有最后一件事我想在舉例前指出。記住,編寫好的行為測試代碼最重要的方面是識別依賴關(guān)系 (你可以在依賴注入中閱讀更多相關(guān)主題) 以及將它們暴露給你的接口。
你的大部分測試將基于你測試對象的狀態(tài),來斷言一個特定交互是否發(fā)生,或者一個特定值是否返回 (或者傳遞給另一個對象)。將依賴提取出來,這可以允許你輕松 mock 值或者狀態(tài)。此外,它將大大簡化斷言一個特定動作的發(fā)生或者特定值是否被計算。
記住,你不應(yīng)該將對象所有的依賴關(guān)系和屬性都暴露在接口之中 (特別是當(dāng)你開始測試的時候,雖然這樣很誘人)。你的接口只應(yīng)該清楚的表述設(shè)計需求,而如果過多暴露依賴和屬性,必將減少你的對象的可讀性和目的的清晰度。
讓我們從一個簡單的例子開始。我們將構(gòu)建一個組件,負責(zé)為給定的事件對象進行文本消息格式化:
@interface EventDescriptionFormatter : NSObject
@property(nonatomic, strong) NSDateFormatter *dateFormatter;
- (NSString *)eventDescriptionFromEvent:(id <Event>)event;
@end
這就是我們接口的樣子。Event 協(xié)議定義了一個事件的三個基本屬性:
@protocol Event <NSObject>
@property(nonatomic, readonly) NSString *name;
@property(nonatomic, readonly) NSDate *startDate;
@property(nonatomic, readonly) NSDate *endDate;
@end
我們的目標是測試 EventDescriptionFormatter
是否返回像 "My Event starts at Aug 21, 2014, 12:00 AM and ends at Aug 21, 2014, 1:00 AM." 這樣的格式化后的描述。
請注意,這里 (以及本文中其他例子) 采用了 mocking 框架。如果你之前沒有用過 mocking 框架,你應(yīng)該向置換測試:Mock,Stub 和其他這篇文章請教。
我們將先 mock 我們組件內(nèi)的時間格式化器 (date formatter) 這個唯一依賴,我們將用創(chuàng)建的 mock 來返回開始和結(jié)束日期的固定字符串。然后我們檢查從事件的格式化器里構(gòu)造返回的字符串是否使用了我們先前 mock 的值。
__block id mockDateFormatter;
__block NSString *eventDescription;
__block id mockEvent;
beforeEach(^{
// 準備 mock date formatter
mockDateFormatter = mock([NSDateFormatter class]);
descriptionFormatter.dateFormatter = mockDateFormatter;
NSDate *startDate = [NSDate mt_dateFromYear:2014 month:8 day:21];
NSDate *endDate = [startDate mt_dateHoursAfter:1];
// 準備 mock 事件
mockEvent = mockProtocol(@protocol(Event));
[given([mockEvent name]) willReturn:@"Fixture Name"];
[given([mockEvent startDate]) willReturn:startDate];
[given([mockEvent endDate]) willReturn:endDate];
[given([mockDateFormatter stringFromDate:startDate]) willReturn:@"Fixture String 1"];
[given([mockDateFormatter stringFromDate:endDate]) willReturn:@"Fixture String 2"];
eventDescription = [descriptionFormatter eventDescriptionFromEvent:mockEvent];
});
it(@"should return formatted description", ^{
expect(eventDescription).to.equal(@"Fixture Name starts at Fixture String 1 and ends at Fixture String 2.");
});
注意我們在這里僅僅測試 EventDescriptionFormatter
是否用 NSDateFormatter
來格式化時間,我們并沒有實際測試格式化的樣式。因此,要嚴格測試組件,我們需要增加額外兩個測試來檢查格式化樣式:
it(@"should have appropriate date style on date formatter", ^{
expect(descriptionFormatter.dateFormatter.dateStyle).to.equal(NSDateFormatterMediumStyle);
});
it(@"should have appropriate time style on date formatter", ^{
expect(descriptionFormatter.dateFormatter.timeStyle).to.equal(NSDateFormatterMediumStyle);
});
雖然現(xiàn)在我們擁有了經(jīng)過完整測試的組件,我們也寫了一些測試,但是這真的是一個很小的組件,不是嗎?讓我們從一個稍微不同的角度來嘗試看看這個問題吧。
上面這個例子并沒有確切地測試 EventDescriptionFormatter
的行為。它主要通過 mock NSDateFormatter
來測試其內(nèi)部實現(xiàn)。我們實際上并不關(guān)心內(nèi)部是否有個日期格式化器。從接口的角度來看,我們完全可以手動地只使用日期來進行格式化。在這里,我們關(guān)心的重點是我們是否能正確獲取字符串。我們需要測試的其實是這個行為。
我們可以通過不 mock NSDateFormatter
來輕松達到這個目標。就像之前說的,我們不需要關(guān)心它是否存在,讓我們從接口中去掉它:
@interface EventDescriptionFormatter : NSObject
- (NSString *)eventDescriptionFromEvent:(id <Event>)event;
@end
下一步當(dāng)然是重構(gòu)我們的測試?,F(xiàn)在我們不再需要知道事件內(nèi)部的 formatter,我們只需要專注于實際的行為:
describe(@"event description from event", ^{
__block NSString *eventDescription;
__block id mockEvent;
beforeEach(^{
NSDate *startDate = [NSDate mt_dateFromYear:2014 month:8 day:21];
NSDate *endDate = [startDate mt_dateHoursAfter:1];
mockEvent = mockProtocol(@protocol(Event));
[given([mockEvent name]) willReturn:@"Fixture Name"];
[given([mockEvent startDate]) willReturn:startDate];
[given([mockEvent endDate]) willReturn:endDate];
eventDescription = [descriptionFormatter eventDescriptionFromEvent:mockEvent];
});
it(@"should return formatted description", ^{
expect(eventDescription).to.equal(@"Fixture Name starts at Aug 21, 2014, 12:00 AM and ends at Aug 21, 2014, 1:00 AM.");
});
});
我們測試變的非常簡單。我們僅僅有一個簡約的設(shè)置塊來準備數(shù)據(jù)模型和調(diào)用測試方法。通過更多地專注于行為的結(jié)果,而不是它實際工作方式,我們簡化了測試套件,同時仍然保留對我們對象功能的測試覆蓋。這正是 BDD 的思想 -- 嘗試思考行為的結(jié)果,而不是實際的實現(xiàn)。
在這個例子中,我們建立一個簡單的數(shù)據(jù)下載器。我們特別專注在我們數(shù)據(jù)下載這個單一行為:發(fā)出請求和取消下載。讓我們從定義接口開始吧:
@interface CalendarDataDownloader : NSObject
@property(nonatomic, weak) id <CalendarDataDownloaderDelegate> delegate;
@property(nonatomic, readonly) NetworkLayer *networkLayer;
- (instancetype)initWithNetworkLayer:(NetworkLayer *)networkLayer;
- (void)updateCalendarData;
- (void)cancel;
@end
當(dāng)然,下面是我們的網(wǎng)絡(luò)層接口
@interface NetworkLayer : NSObject
// 傳入標識符來取消請求
- (id)makeRequest:(id <NetworkRequest>)request completion:(void (^)(id <NetworkRequest>, id, NSError *))completion;
- (void)cancelRequestWithIdentifier:(id)identifier;
@end
首先我們檢查實際下載是否發(fā)生。 mock 的網(wǎng)絡(luò)層已經(jīng)在 describe
之前被創(chuàng)建并注入:
describe(@"update calendar data", ^{
beforeEach(^{
[calendarDataDownloader updateCalendarData];
});
it(@"should make a download data request", ^{
[verify(mockNetworkLayer) makeRequest:instanceOf([CalendarDataRequest class]) completion:anything()];
});
});
這部分相當(dāng)簡單,下一步是檢查在我們調(diào)用取消方法之后請求是否被取消。我們需要確保在沒有標識符的情況下我們不調(diào)用取消方法。這種行為的測試看起來像這樣:
describe(@"cancel ", ^{
context(@"when there's an identifier", ^{
beforeEach(^{
calendarDataDownloader.identifier = @"Fixture Identifier";
[calendarDataDownloader cancel];
});
it(@"should tell the network layer to cancel request", ^{
[verify(mockNetworkLayer) cancelRequestWithIdentifier:@"Fixture Identifier"];
});
it(@"should remove the identifier", ^{
expect(calendarDataDownloader.identifier).to.beNil();
});
});
context(@"when there's no identifier", ^{
beforeEach(^{
calendarDataDownloader.identifier = nil;
[calendarDataDownloader cancel];
});
it(@"should not ask the network layer to cancel request", ^{
[verifyCount(mockNetworkLayer, never()) cancelRequestWithIdentifier:anything()];
});
});
});
請求標識符是 CalendarDataDownloader
其中一個私有屬性,所以我們需要使得它暴露在我們的測試中:
@interface CalendarDataDownloader (Specs)
@property(nonatomic, strong) id identifier;
@end
你大概可以衡量這些測試中有一些錯誤。盡管對于檢查特定行為這樣是有效的,但它們暴露了 CalendarDataDownloader
內(nèi)部的工作。這里不需要測試 CalendarDataDownloader
如何持有它的請求標識符。讓我們看看我們?nèi)绾卧诓槐┞段覀儍?nèi)部實現(xiàn)的情況下描述我們的測試:
describe(@"update calendar data", ^{
beforeEach(^{
[given([mockNetworkLayer makeRequest:instanceOf([CalendarDataRequest class])
completion:anything()]) willReturn:@"Fixture Identifier"];
[calendarDataDownloader updateCalendarData];
});
it(@"should make a download data request", ^{
[verify(mockNetworkLayer) makeRequest:instanceOf([CalendarDataRequest class]) completion:anything()];
});
describe(@"canceling request", ^{
beforeEach(^{
[calendarDataDownloader cancel];
});
it(@"should tell the network layer to cancel previous request", ^{
[verify(mockNetworkLayer) cancelRequestWithIdentifier:@"Fixture Identifier"];
});
describe(@"canceling it again", ^{
beforeEach(^{
[calendarDataDownloader cancel];
});
it(@"should tell the network layer to cancel previous request", ^{
[verify(mockNetworkLayer) cancelRequestWithIdentifier:@"Fixture Identifier"];
});
});
});
});
我們通過 stub makeRequest:completion:
方法開始。我們返回一個固定的標識符。在相同的 describe
塊內(nèi),我們定義了取消請求的 describe
塊,用以在 CalendarDataDownloader
類中調(diào)用 cancel
方法。接著我們檢查我們的固定字符串是否傳入到我們所 mock 的網(wǎng)絡(luò)層中的 cancelRequestWithIdentifier:
方法。
請注意,在這里我們實際上并不需要測試檢查網(wǎng)絡(luò)請求是否被執(zhí)行 - 如果沒有執(zhí)行的話,我們就不會得到一個標識符并且 cancelRequestWithIdentifier:
也永遠不會被調(diào)用。然而,我們保留了那個測試,來確保在當(dāng)功能被破壞的時候我們能知道發(fā)生了什么。
我們已經(jīng)設(shè)法在不暴露 CalendarDataDownloader
內(nèi)部實現(xiàn)的同時測試了相同的行為。此外,我們用三個測試代替了之前四個。我們利用 BDD DSL 嵌套能力來束縛模擬的多重行為 -- 我們首先模擬下載,接著,在相同的 describe
塊內(nèi),我們模擬取消請求。
在 iOS 開發(fā)者中對于測試視圖控制器的最常見的態(tài)度是看不到價值所在。這讓我覺得奇怪,控制器經(jīng)常代表應(yīng)用程序的核心。它們是將所有組件粘合在一起的地方,它們是建立了用戶界面和應(yīng)用邏輯及模型之間的聯(lián)系的地方。因此,不經(jīng)意的變化可能造成巨大的破壞。
這就是為什么我堅信視圖控制器也必須被測試。然而,測試視圖控制器并不是一件簡單的工作。接下來的上傳圖片和登錄視圖控制器例子會幫助理解,如何利用 BDD 簡化構(gòu)建視圖控制器測試套件。
在這個例子中,我們需要建立一個簡單的上傳圖片控制器,它包含一個 rightBarButtonItem
按鈕。在按鈕點擊后,視圖控制器將通知上傳圖片組件,應(yīng)該上傳圖片。
是不是很簡單?讓我們從 PhotoUploaderViewController
接口開始:
@interface PhotoUploadViewController : UIViewController
@property(nonatomic, readonly) PhotoUploader *photoUploader;
- (instancetype)initWithPhotoUploader:(PhotoUploader *)photoUploader;
@end
在這里我們除了定義了一個 PhotoUploader
的額外依賴外并沒有做其他事情。我們的實現(xiàn)也同樣非常簡單,為了簡單起見,我們并不會實際選取照片;我們只是建立一個空的 UIImage
:
@implementation PhotoUploadViewController
- (instancetype)initWithPhotoUploader:(PhotoUploader *)photoUploader {
self = [super init];
if (self) {
_photoUploader = photoUploader;
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Upload", nil) style:UIBarButtonItemStyleBordered target:self action:@selector(didTapUploadButton:)];
}
return self;
}
#pragma mark -
- (void)didTapUploadButton:(UIBarButtonItem *)uploadButton {
void (^completion)(NSError *) = ^(NSError* error){};
[self.photoUploader uploadPhoto:[UIImage new] completion:completion];
}
@end
讓我們看下如何測試這個組件,首先,需要斷言我們這個 bar 按鈕是否初始化了 title,target 和 action 屬性:
describe(@"right bar button item", ^{
__block UIBarButtonItem *barButtonItem;
beforeEach(^{
barButtonItem = [[photoUploadViewController navigationItem] rightBarButtonItem];
});
it(@"should have a title", ^{
expect(barButtonItem.title).to.equal(@"Upload");
});
it(@"should have a target", ^{
expect(barButtonItem.target).to.equal(photoUploadViewController);
});
it(@"should have an action", ^{
expect(barButtonItem.action).to.equal(@selector(didTapUploadButton:));
});
});
但是我們僅僅完成了測試功能的一半:我們現(xiàn)在確保了當(dāng)按鈕按下的時候適當(dāng)?shù)姆椒ū粓?zhí)行,但是我們無法確定適當(dāng)?shù)膭幼鞅粓?zhí)行 (我們實際上甚至不知道這個方法有沒有被創(chuàng)建)。接下來讓我們開始測試這些:
describe(@"tapping right bar button item", ^{
beforeEach(^{
[photoUploadViewController didTapUploadButton:nil];
});
it(@"should tell the mock photo uploader to upload the photo", ^{
[verify(mockPhotoUploader) uploadPhoto:instanceOf([UIImage class])
completion:anything()];
});
});
不幸的是,didTapUploadButton:
在接口中不可見,我們可以在測試中通過定義一個可見類別暴露方法來解決問題。
@interface PhotoUploadViewController (Specs)
- (void)didTapUploadButton:(UIBarButtonItem *)uploadButton;
@end
這個時候,我們可以說 PhotoUploadViewController
被完全測試了。
但是以上例子有什么問題?問題是我們測試了 PhotoUploadViewController
內(nèi)部實現(xiàn)。我們不應(yīng)該實際關(guān)心按鈕上 target/action 的值,我們應(yīng)該專注于它被點擊時會發(fā)生什么。其他都是實現(xiàn)細節(jié)。
讓我們回頭看看 PhotoUploadViewController
,并討論如何重寫測試確保我們只測試我們的界面,而不是實現(xiàn)。
首先,我們不需要知道 didTapUploadButton:
方法存在與否。它只是實現(xiàn)細節(jié)。我們應(yīng)該只關(guān)心行為:當(dāng)用戶點擊上傳按鈕,UploadManager
應(yīng)該收到一個 uploadPhoto:
消息。這太好了,因為這表示我們不再需要在 PhotoUploadViewController
中添加 Specs
類別
接下來,我們不需要知道 rightBarButtonItem
中的 target/action 的定義。我們僅只需要關(guān)注當(dāng)點擊的時候發(fā)生了什么。讓我們在測試中模擬這個動作,我們可以為 UIBarButtonItem
創(chuàng)建一個 helper 類別來完成這件事情:
@interface UIBarButtonItem (Specs)
- (void)specsSimulateTap;
@end
這個實現(xiàn)相當(dāng)簡單,就只是在 UIBarButtonItem
的 target
中執(zhí)行 action
:
@implementation UIBarButtonItem (Specs)
- (void)specsSimulateTap {
[self.target performSelector:self.action withObject:self];
}
@end
現(xiàn)在我們已經(jīng)創(chuàng)建了 helper 方法模擬點擊,我們可以在最上級的 describe
塊中簡化測試:
describe(@"right bar button item", ^{
__block UIBarButtonItem *barButtonItem;
beforeEach(^{
barButtonItem = [[photoUploadViewController navigationItem] rightBarButtonItem];
});
it(@"should have a title", ^{
expect(barButtonItem.title).to.equal(@"Upload");
});
describe(@"when it is tapped", ^{
beforeEach(^{
[barButtonItem specsSimulateTap];
});
it(@"should tell the mock photo uploader to upload the photo", ^{
[verify(mockPhotoUploader) uploadPhoto:instanceOf([UIImage class])
completion:anything()];
});
});
});
值得注意的是我們設(shè)法消除了兩個測試后我們?nèi)匀粨碛幸粋€嚴格測試的組件。此外,我們的測試套件不易被打破,我們不再依賴于 didTapUploadButton:
方法。最后同樣重要的,我們更關(guān)注我們控制器行為,而不是它的內(nèi)部實現(xiàn)。
在這個例子中,我們將構(gòu)建一個簡單的應(yīng)用程序,要求用戶輸入用戶名和密碼以登錄到一個抽象的服務(wù)。
我們將通過構(gòu)建一個包含兩個文本框以及一個登錄按鈕的 SignInViewController
。應(yīng)該確保我們的控制器盡可能的輕量級,所以我們把負責(zé)登錄的組件抽象到一個稱為 SignInManager
的單獨的類中。
我們的需求如下:當(dāng)用戶點擊登錄按鈕,并且用戶名和密碼已經(jīng)填寫,我們的視圖控制器將告訴 SignInManager
利用用戶名和密碼來執(zhí)行登錄。如果沒有填寫用戶名或者密碼 (或者兩者都沒填寫),app 將在文本框上方顯示一個錯誤信息。
我們需要測試的視圖部分是:
@interface SignInViewController : UIViewController
@property(nonatomic, readwrite) IBOutlet UIButton *signInButton;
@property(nonatomic, readwrite) IBOutlet UITextField *usernameTextField;
@property(nonatomic, readwrite) IBOutlet UITextField *passwordTextField;
@property(nonatomic, readwrite) IBOutlet UILabel *fillInBothFieldsLabel;
@property(nonatomic, readonly) SignInManager *signInManager;
- (instancetype)initWithSignInManager:(SignInManager *)signInManager;
- (IBAction)didTapSignInButton:(UIButton *)signInButton;
@end
首先,我們會檢查一些基本的文本字段
beforeEach(^{
// Force view load from xib
[signInViewController view];
});
it(@"should have a placeholder on user name text field", ^{
expect(signInViewController.usernameTextField.placeholder).to.equal(@"Username");
});
it(@"should have a placeholder on user name text field", ^{
expect(signInViewController.passwordTextField.placeholder).to.equal(@"Password");
});
接著,我們需要檢查登錄按鈕是否正確配置而且 action 已經(jīng)連接:
describe(@"sign in button", ^{
__block UIButton *button;
beforeEach(^{
button = signInViewController.signInButton;
});
it(@"should have a title", ^{
expect(button.currentTitle).to.equal(@"Sign In");
});
it(@"should have sign in view controller as only target", ^{
expect(button.allTargets).to.equal([NSSet setWithObject:signInViewController]);
});
it(@"should have the sign in action as action for login view controller target", ^{
NSString *selectorString = NSStringFromSelector(@selector(didTapSignInButton:));
expect([button actionsForTarget:signInViewController forControlEvent:UIControlEventTouchUpInside]).to.equal(@[selectorString]);
});
});
最后同樣重要的是,我們檢查當(dāng)按鈕被按下時候的控制器的行為:
describe(@"tapping the logging button", ^{
context(@"when login and password are present", ^{
beforeEach(^{
signInViewController.usernameTextField.text = @"Fixture Username";
signInViewController.passwordTextField.text = @"Fixture Password";
// Make sure state is different than the one expected //確保狀態(tài)與預(yù)期不同
signInViewController.fillInBothFieldsLabel.alpha = 1.0f;
[signInViewController didTapSignInButton:nil];
});
it(@"should tell the sign in manager to sign in with given username and password", ^{
[verify(mockSignInManager) signInWithUsername:@"Fixture Username" password:@"Fixture Password"];
});
});
context(@"when login or password are not present", ^{
beforeEach(^{
signInViewController.usernameTextField.text = @"Fixture Username";
signInViewController.passwordTextField.text = nil;
[signInViewController didTapSignInButton:nil];
});
it(@"should tell the sign in manager to sign in with given username and password", ^{
[verifyCount(mockSignInManager, never()) signInWithUsername:anything() password:anything()];
});
});
context(@"when neither login or password are present", ^{
beforeEach(^{
signInViewController.usernameTextField.text = nil;
signInViewController.passwordTextField.text = nil;
[signInViewController didTapSignInButton:nil];
});
it(@"should tell the sign in manager to sign in with given username and password", ^{
[verifyCount(mockSignInManager, never()) signInWithUsername:anything() password:anything()];
});
});
});
上面的例子中提到的代碼有相當(dāng)多的問題。首先我們暴露了過多 SignInViewController
內(nèi)部實現(xiàn),包括按鈕,文本框以及方法。事實是,我們并不真正需要把所有這一切都做一遍。
讓我們看看如何重構(gòu)這些測試來確保沒有觸碰到內(nèi)部實現(xiàn)。我們將通過刪除登錄按鈕的 target 和 method 的鉤子來開始:
@interface UIButton (Specs)
- (void)specsSimulateTap;
@end
@implementation UIButton (Specs)
- (void)specsSimulateTap {
[self sendActionsForControlEvents:UIControlEventTouchUpInside];
}
@end
現(xiàn)在我們可以僅通過調(diào)用我們的按鈕上的這個方法,并斷言 SignInManager
是否收到相應(yīng)的消息。但是我們依然可以改善這個測試的寫法。
讓我們假設(shè)我們不想知道誰擁有這個登錄按鈕,也許這是一個視圖控制器視圖的子視圖,又或者我們將它封裝在了單獨的視圖里并將自己作為它的代理。我們實際上不應(yīng)該關(guān)心它在哪;我們應(yīng)該只關(guān)心它是否在我們視圖控制器視圖的某個地方,并且當(dāng)我們點擊時候會發(fā)生什么。我們可以用一個輔助方法來抓取登錄按鈕,而不用關(guān)心它在哪:
@interface UIView (Specs)
- (UIButton *)specsFindButtonWithTitle:(NSString *)title;
@end
我們的方法將遍歷視圖中的所有子視圖并返回第一個和我們 title 參數(shù)匹配的按鈕,我們可以為文本框或標簽寫類似的方法:
@interface UIView (Specs)
- (UITextField *)specsFindTextFieldWithPlaceholder:(NSString *)placeholder;
- (UILabel *)specsFindLabelWithText:(NSString *)text;
@end
讓我們看看現(xiàn)在測試的樣子:
describe(@"view", ^{
__block UIView *view;
beforeEach(^{
view = [signInViewController view];
});
describe(@"login button", ^{
__block UITextField *usernameTextField;
__block UITextField *passwordTextField;
__block UIButton *signInButton;
beforeEach(^{
signInButton = [view specsFindButtonWithTitle:@"Sign In"];
usernameTextField = [view specsFindTextFieldWithPlaceholder:@"Username"];
passwordTextField = [view specsFindTextFieldWithPlaceholder:@"Password"];
});
context(@"when login and password are present", ^{
beforeEach(^{
usernameTextField.text = @"Fixture Username";
passwordTextField.text = @"Fixture Password";
[signInButton specsSimulateTap];
});
it(@"should tell the sign in manager to sign in with given username and password", ^{
[verify(mockSignInManager) signInWithUsername:@"Fixture Username" password:@"Fixture Password"];
});
});
context(@"when login or password are not present", ^{
beforeEach(^{
usernameTextField.text = @"Fixture Username";
passwordTextField.text = nil;
[signInButton specsSimulateTap];
});
it(@"should tell the sign in manager to sign in with given username and password", ^{
[verifyCount(mockSignInManager, never()) signInWithUsername:anything() password:anything()];
});
});
context(@"when neither login or password are present", ^{
beforeEach(^{
usernameTextField.text = nil;
passwordTextField.text = nil;
[signInButton specsSimulateTap];
});
it(@"should tell the sign in manager to sign in with given username and password", ^{
[verifyCount(mockSignInManager, never()) signInWithUsername:anything() password:anything()];
});
});
});
});
看起來是不是更簡單?我們通過 "Sign in" 的標題來尋找按鈕的同時,也測試了這個按鈕是否存在。此外,通過模擬一個點擊請求,我們測試了動作是否被正確連接。最后,通過斷言 SignInManager
被調(diào)用與否,我們測試這部分功能有沒有正確被實現(xiàn) -- 所有這些都用三個簡單的測試實現(xiàn)了。
另一件很棒的事是我們不再需要暴露任何內(nèi)部屬性。事實上,我們的接口可能非常簡單,比如:
@interface SignInViewController : UIViewController
@property(nonatomic, readonly) SignInManager *signInManager;
- (instancetype)initWithSignInManager:(SignInManager *)signInManager;
@end
另外一件好事是這些測試我們利用了 BDD DSL 的功能。注意我們使用 context
塊為 SignInViewController
根據(jù)不同的需求行為來定義其文本字段狀態(tài)。這是一個如何使用 BDD 來在保持測試功能特性的同時將它們變得簡單可讀的好例子。
行為驅(qū)動開發(fā)看起來并不像最初那么困難。所有你需要的只是改變你的思維方式 -- 更多思考一個對象的行為 (它的接口應(yīng)該如何) 并且減少對實現(xiàn)的關(guān)注。通過這樣做,你將擁有更健壯的代碼,以及同樣杰出的測試套件。此外,你的測試在生產(chǎn)代碼修改時失效的可能性會降低,它們將專注于測試對象的行為而不是內(nèi)部實現(xiàn)。
并且 iOS 社區(qū)提供了如此杰出的工具,這讓你可以立即開始對你 app 的行為驅(qū)動開發(fā)。同時你知道了應(yīng)該測試什么,沒有任何借口不這樣做了,不是嗎?
如果你對 BDD 的起源很感興趣,如何產(chǎn)生的,你絕對應(yīng)該讀一下這篇文章。 對于那些了解 TDD 的用戶,但是不確切知道它和 BDD 的區(qū)別的用戶,我建議這篇文章。 最后最重要的,你可以在這里找到本文出現(xiàn)的測試例子。