玩轉 Objective-C 的 Mock 對象

測試驅動開發(TDD)中,開發者常用模擬對象進行系統設計,模擬對象究竟是什麼呢?部分模擬對象和所有模擬對象又是什麼呢?模擬對象真的讓人又愛又恨嗎?讓咱們以Objective-C測試框架OCMock來探個究竟。app

模擬對象設計

模擬對象能夠解決兩種問題。第一種是(它們也是所以而提出的)用於設計測試驅動開發的測試類。想象一下,你已經完成了第一個測試,並知道了一些關於第一個類的API的信息。你的測試調用了新類的方法,你知道,應該從它們協做者之一種抓取一些信息。問題是,協做者尚不存在,而你又不想放棄這個已經設計出來的並開始測試的類。框架

此時,你能夠建立一個模擬對象表明這個還沒有「出生」的協做者。你能夠設定你想要經過該「協做者」測試調用對象的指望值,並且,若是須要的話,還能夠返回一個能夠測試控制的值。你的測試能夠驗證你所指望調用的方法是否真的被調用了,若是沒有,則測試失敗。函數

在這種狀況下,模擬對象就像一臺VCR,只是沒有上世紀八十年代的矮胖的造型和易受損的磁帶。測試期間,模擬對象會記錄你發送給它的每一條消息。而後,能夠經過重放與消息列表作比較來看是否是你所須要的。就像用VCR,若是你想要看的是小精靈2(Gremlins 2),可是記錄的倒是上半年的新聞和歡樂酒店(Cheers),這就讓人較爲失望。測試

關鍵的部分是,你實際上並不須要創建真正的協做對象。事實上,你徹底不須要關心它是怎麼實施的。惟一須要關注的是它須要返回的消息,這樣就能夠驗證他們是否被髮送了。實際上,模擬對象可讓你以爲說,「我知道,在某些時候,我會考慮這一點,但我不但願所以而分心。」 對於測試驅動開發者,這就像一個待辦事項清單同樣清晰。fetch

讓咱們來看一個例子。假設書呆子Ranch發現了市場上對博物館庫存管理App的需求。一般博物館收藏了大量的文物,他們須要瞭解全部的庫存,並能按主題,國家,年代等在畫廊組織展覽。關於庫存的需求相似以下:編碼

做爲策展人,我想知道全部須要展出的文物,這樣我就能夠給個人遊客們講故事了。翻譯

我會寫一個能夠提供一個全部文物的清單的庫存類用來測試。固然,磁盤上還有其餘類也存儲了全部的文物,可是我不關心他們是如何工做的,我只要建立一個庫存接口的模擬對象。個人測試類以下:設計

@implementation BNRMuseumInventoryTests

- (void)testArtefactsAreRetrievedFromTheStore

{

    //Assemble

    id store = [OCMockObject mockForProtocol:@protocol(BNRInventoryStore)];

    BNRMuseumInventory *inventory = [[BNRMuseumInventory alloc] initWithStore:store];

    NSArray *expectedArtefacts = @[@"An artefact"];

    [[[store expect] andReturn:expectedArtefacts] fetchAllArtefacts];

    //Act

    NSArray *allArtefacts = [inventory allArtefacts];

    //Assert

    XCTAssertEqualObjects(allArtefacts, expectedArtefacts);

    [store verify];

}

@end

爲了讓這個類編譯經過,我須要建立BNRMuseumInventory類和它的initWithStore:和allArtefacts方法。代理

@interface BNRMuseumInventory : NSObject

- (id)initWithStore:(id <BNRInventoryStore>)store;

- (NSArray *)allArtefacts;

@end

@implementation BNRMuseumInventory

- (id)initWithStore:(id <BNRInventoryStore>)store

{

return nil;

}

- (NSArray *)allArtefacts

{

return nil;

}

@end

我還要定義BNRInventoryStore協議及其-fetchAllArtefacts方法,但我如今還不須要實現它們。爲何要我將它定義爲一個協議,而不是另外一個類?是爲了提升靈活性:我知道我想發送給BNRInventoryStore的消息,但我並不須要關心它是如何處理這些消息的。使用協議能讓我靈活的處理實現存儲的方法:只要它能響應我所關心的消息,它能夠是任何類型的類。code

@protocol BNRInventoryStore <NSObject>

- (NSArray *)fetchAllArtefacts;

@end

如今有足夠的信息讓編譯器來編譯和運行測試,但它仍是不能經過。

Test Case '-[BNRMuseumInventoryTests testArtefactsAreRetrievedFromTheStore]' started.

/Users/leeg/BNRMuseumInventory/BNRMuseumInventory Tests/BNRMuseumInventoryTests.m:91: error: -[BNRMuseumInventoryTests testArtefactsAreRetrievedFromTheStore] : ((allArtefacts) equal to (expectedArtefacts)) failed: ("(null)") is not equal to ("(

"An artefact"

)")

<unknown>:0: error: -[BNRMuseumInventoryTests testArtefactsAreRetrievedFromTheStore] : OCMockObject[BNRInventoryStore]: expected method was not invoked: fetchAllArtefacts

// snip more output

在測試中斷言檢測到期待的文物集合並未返回,fetchAllArtefacts方法沒有被調用,模擬對象驗證失敗。只有修復這兩個問題,咱們才能夠經過測試。

@implementation BNRMuseumInventory

{

id <BNRInventoryStore> _store;

}

- (id)initWithStore:(id <BNRInventoryStore>)store

{

self = [super init];

if (self)

{

_store = store;

}

return self;

}

- (NSArray *)allArtefacts

{

return [_store fetchAllArtefacts];

}

@end

模擬一體化

第二種使用模擬對象的方法是使用外部代碼,如蘋果的框架或第三方庫,進行一體化。模擬對象能夠簡化使用框架所帶來的複雜性,由於測試並不須要搭建一個成熟的環境,只需確保咱們的應用程序能鏈接到該環境中的一小部分。這種使用模擬對象的模式叫作謙卑對象(Humble Object)。

繼續VCR的比喻,咱們並無設計一個與框架交互的類,但咱們要檢查咱們是否遵照了他們規定的規則。就像了你買了一臺VHS錄像機,但你不須要知道磁帶的類型,你只能使用VHS錄像帶,由於這是廠家規定的。一樣的,咱們能夠告訴咱們的模擬對象,指望值是VHS磁帶,因此若是咱們給它一個錄像帶Betamax,測試將會失敗。

回到咱們的博物館例子中,當應用程序啓動時,首先應該看到的是博物館全部文物的清單,這可使用UIKit設置窗口的根視圖控制器來實現。可是要設置整個窗口的測試環境,會很是慢且複雜,因此咱們用一個模擬對象替換窗口。

- (void)testFirstScreenIsTheListOfAllArtefacts

{

BNRAppDelegate *appDelegate = [[BNRAppDelegate alloc] init];

id window = [OCMockObject mockForClass:[UIWindow class]];

appDelegate.window = window;

[[window expect] setRootViewController:[OCMArg checkWithBlock:^(id viewController) {

return [viewController isKindOfClass:[BNRAllArtefactsTableViewController class]];

}]];

[appDelegate application:nil didFinishLaunchingWithOptions:nil];

[window verify];

}

@end

爲了使這個測試經過,須實現應用程序的委託方法。

@implementation BNRAppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options

{

self.window.rootViewController = [[BNRAllArtefactsTableViewController alloc] initWithStyle:UITableViewStyleGrouped];
return YES;

}

@end

徹底模擬

該例子中,還有另一個需求:當加載一個UIKit的應用程序時:包含初始視圖控制器的窗口必須是主要且可見的。咱們能夠添加一個測試表達這一要求。請注意,因爲這個測試和以前的測試使用的是相同的對象,該構造函數能夠被分解成一個setup方法。

@implementation BNRAppDelegateTests

{

BNRAppDelegate *_appDelegate;

id _window;

}

- (void)setUp

{

_appDelegate = [[BNRAppDelegate alloc] init];

_window = [OCMockObject mockForClass:[UIWindow class]];

appDelegate.window = _window;

}

- (void)testWindowIsMadeKeyAndVisible

{

[[_window expect] makeKeyAndVisible];

[_appDelegate application:nil didFinishLaunchingWithOptions:nil];

[_window verify];

}

- (void)testFirstScreenIsTheListOfArtefacts

{

[[_window expect] setRootViewController:[OCMArg checkWithBlock:^(id viewController) {

return [viewController isKindOfClass:[BNRAllArtefactsTableViewController class]];

}];

[_appDelegate application:nil didFinishLaunchingWithOptions:nil];

[_window verify];

}

@end

如今咱們遇到了一個棘手的問題。新測試失敗的緣由有兩個:預期的makeKeyAndVisible消息沒有被髮送,卻正在發送一個意外的消息setRootViewController:.在 [BNRAppDelegate application:didFinishLaunchingWithOptions:]方法中添加 -makeKeyAndVisible消息 -表示兩個測試都失敗了,由於模擬窗口對象在每一個測試都接收了一個未期待的方法。

徹底模擬能夠解決這個問題。徹底模擬對象可記錄它接收到的全部消息,就像一個普通的模擬消息對象,包括不期待的消息。這就像說,「我想記錄星際旅行的那個情節:航海者,但若是在這以前有天氣預報,我也不介意」,它忽略了額外的信息,而且不考慮致使測試失敗的消息。

咱們能夠在setUp方法中把這個測試的模擬窗口改爲一個徹底模擬。

- (void)setUp

{

_appDelegate = [[BNRAppDelegate alloc] init];

_window = [OCMockObject niceMockForClass:[UIWindow class]];

appDelegate.window = _window;

}

如今,它能夠改變應用程序的委託,這樣兩個測試均可以經過。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options

{

self.window.rootViewController = [[BNRAllArtefactsTableViewController alloc] initWithStyle:UITableViewStyleGrouped];

[self.window makeKeyAndVisible];
return YES;

}

部分模擬

有時候,你並不須要用模擬取代全部對象的行爲。你只是想消除一些依賴或複雜的行爲,並在你要測試的方法使用其結果。你能夠建立一個子類,並重寫複雜的方法,此時使用部分模擬會更容易。部分模擬做爲真正的對象的代理,截取了部分消息,可是仍然可使用那些沒有被替換的消息的實現方法。

再回到咱們的博物館庫存的App例子中,策展人須要將文物的原產地做爲篩選條件。這意味着須要全部的文物清單,並對這些對象作一些測試,而咱們所作的是使allArtefacts方法與庫存對象進行溝通。但這並非咱們在本次測試須要關心的事情:咱們要專一於篩選,且不重複咱們在以前已經完成的測試工做。使用庫存對象的部分模擬就可讓咱們去掉樁對象的那部分。這個測試類也會影響文物數據模型的設計。

@implementation BNRMuseumInventoryTests

{

BNRMuseumInventory *_inventory; //created in -setUp

}

//...

- (void)testArtefactsCanBeFilteredByCountryOfOrigin

{

id romanPot = [OCMockObject mockForProtocol:@protocol(BNRArtefact)];

[[[romanPot stub] andReturn:@"Italy"] countryOfOrigin];

id greekPot = [OCMockObject mockForProtocol:@protocol(BNRArtefact)];

[[[greekPot stub] andReturn:@"Greece"] countryOfOrigin];

id partialInventory = [OCMockObject partialMockForObject:_inventory];

[[[partialInventory stub] andReturn:@[romanPot, greekPot]] allArtefacts];

NSArray *greekArtefacts = [partialInventory artefactsFromCountry:@"Greece"];

XCTAssertTrue([greekArtefacts containsObject:greekPot]);

XCTAssertFalse([greekArtefacts containsObject:romanPot]);

}

@end

在上面的測試中,我用OCMock的-stub方法,而不是-expect方法。該方法告訴模擬對象處理該消息並返回指定的值(若是有),但不設置該測試稍後需驗證的消息的指望值。我能夠經過artefactsFromCountry的返回值來辨別代碼是否有用,我並不須要關心如何實現(但若是你擔憂硬編碼的一些做弊行爲,譬如:一般都會返回集合中的最後一個對象,你能夠簡單地添加更多的測試)。

這個測試告訴咱們一些關於BNRArtefact協議的事情。

- (NSString *)countryOfOrigin;

@end

如今就能夠建立artfactsFromCountry:方法。

- (NSArray *)artefactsFromCountry:(NSString *)country

{

NSArray *artefacts = [self allArtefacts];

NSIndexSet *locationsOfMatchingArtefacts = [artefacts indexesOfObjectsPassingTest:^(id <BNRArtefact> anArtefact, NSUInteger idx, BOOL *stop){

return [[anArtefact countryOfOrigin] isEqualToString:country];

}];

return [artefacts objectsAtIndexes:locationsOfMatchingArtefacts];

}

結論

當你構建應用程序的測試驅動時,模擬對象能幫助你集中注意力。他們讓你專一於你如今正在作的測試,同時推遲對你未建立對象的測試。他們讓你專一於你正在測試的對象的部分,忽略你已經測試過或還沒有測試的東西。他們還讓你專一於你本身的代碼,用簡單的類代替複雜的框架類。

若是你由文中VCR想到了你家的那部錄音機,那它估計已經到了進博物館的年紀了,而咱們剛剛寫的文物庫存管理應用程序,它會在那找到一個溫馨的家。


原文 Making a Mockery with Mock Objects
翻譯 伯樂在線 - Stellar

相關文章
相關標籤/搜索