測試驅動開發(TDD)中,開發者經常使用模擬物件進行系統設計,模擬物件到底是什麼呢?部分模擬物件和全部模擬物件又是什麼呢?模擬物件真的讓人又愛又恨嗎?讓我們以Objective-C測試框架OCMock來探個究竟。
模擬物件設計
模擬物件可以解決兩種問題。第一種是(它們也是因此而提出的)用於設計測試驅動開發的測試類。想象一下,你已經完成了第一個測試,並知道了一些關於第一個類的API的資訊。你的測試呼叫了新類的方法,你知道,應該從它們協作者之一種抓取一些資訊。問題是,協作者尚不存在,而你又不想放棄這個已經設計出來的並開始測試的類。
此時,你可以建立一個模擬物件代表這個尚未“出生”的協作者。你可以設定你想要通過該“協作者”測試呼叫物件的期望值,而且,如果需要的話,還可以返回一個可以測試控制的值。你的測試可以驗證你所期望呼叫的方法是否真的被呼叫了,如果沒有,則測試失敗。
在這種情況下,模擬物件就像一臺VCR,只是沒有上世紀八十年代的矮胖的造型和易受損的磁帶。測試期間,模擬物件會記錄你傳送給它的每一條訊息。然後,可以通過重放與訊息列表做比較來看是不是你所需要的。就像用VCR,如果你想要看的是小精靈2(Gremlins 2),但是記錄的卻是上半年的新聞和歡樂酒店(Cheers),這就讓人較為失望。
關鍵的部分是,你實際上並不需要建立真正的協作物件。事實上,你完全不需要關心它是怎麼實施的。唯一需要關注的是它需要返回的訊息,這樣就可以驗證他們是否被髮送了。實際上,模擬物件可以讓你覺得說,“我知道,在某些時候,我會考慮這一點,但我不希望因此而分心。” 對於測試驅動開發者,這就像一個待辦事項清單一樣清晰。
讓我們來看一個例子。假設書呆子Ranch發現了市場上對博物館庫存管理App的需求。通常博物館收藏了大量的文物,他們需要了解所有的庫存,並能按主題,國家,年代等在畫廊組織展覽。關於庫存的需求類似如下:
“作為策展人,我想知道所有需要展出的文物,這樣我就可以給我的遊客們講故事了”。
我會寫一個可以提供一個所有文物的清單的庫存類用來測試。當然,磁碟上還有其他類也儲存了所有的文物,但是我不關心他們是如何工作的,我只要建立一個庫存介面的模擬物件。我的測試類如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
@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方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
@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的訊息,但我並不需要關心它是如何處理這些訊息的。使用協議能讓我靈活的處理實現儲存的方法:只要它能響應我所關心的訊息,它可以是任何型別的類。
1 2 3 4 5 |
@protocol BNRInventoryStore <NSObject> - (NSArray *)fetchAllArtefacts; @end |
現在有足夠的資訊讓編譯器來編譯和執行測試,但它還是不能通過。
1 2 3 4 5 6 7 8 9 10 11 |
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方法沒有被呼叫,模擬物件驗證失敗。只有修復這兩個問題,我們才可以通過測試。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
@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設定視窗的根檢視控制器來實現。但是要設定整個視窗的測試環境,會非常慢且複雜,所以我們用一個模擬物件替換視窗。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
- (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 |
為了使這個測試通過,須實現應用程式的委託方法。
1 2 3 4 5 6 7 8 9 10 11 12 |
@implementation BNRAppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options { self.window.rootViewController = [[BNRAllArtefactsTableViewController alloc] initWithStyle:UITableViewStyleGrouped]; return YES; } @end |
完全模擬
該例子中,還有另外一個需求:當載入一個UIKit的應用程式時:包含初始檢視控制器的視窗必須是主要且可見的。我們可以新增一個測試表達這一要求。請注意,由於這個測試和之前的測試使用的是相同的物件,該建構函式可以被分解成一個setup方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
@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方法中把這個測試的模擬視窗改成一個完全模擬。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
- (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方法與庫存物件進行溝通。但這並不是我們在本次測試需要關心的事情:我們要專注於篩選,且不重複我們在之前已經完成的測試工作。使用庫存物件的部分模擬就可以讓我們去掉樁物件的那部分。這個測試類也會影響文物資料模型的設計。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
@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協議的事情。
1 2 3 |
- (NSString *)countryOfOrigin; @end |
現在就可以建立artfactsFromCountry:方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
- (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想到了你家的那部錄音機,那它估計已經到了進博物館的年紀了,而我們剛剛寫的文物庫存管理應用程式,它會在那找到一個舒適的家。