在理想情況下,你所做的所有測試都是能應對你實際代碼的高級測試。例如,UI 測試將模擬實際的用戶輸入(Klaas 在他的文章中有討論)等等。實但際上,這并非永遠都是個好主意。為每個測試用例都訪問一次數(shù)據(jù)庫或者旋轉(zhuǎn)一次 UI 會使你的測試跑得非常慢,這會降低你的生產(chǎn)力,并導致你不去經(jīng)常跑那些測試。若你測試的某段代碼依賴于網(wǎng)絡連接,這會要求你的測試環(huán)境具備網(wǎng)絡接入條件,而且這也難以模擬某些特殊的測試,比如當電話處于飛行模式情況下的時候。
正因如此,我們可以用一些模擬代碼替換你的實際代碼來編寫一些測試用例。
讓我們從以下這些不同類型的模擬對象的基本定義開始。
double 可以理解為置換,它是所有模擬測試對象的統(tǒng)稱,我們也可以稱它為替身。一般來說,當你創(chuàng)建任意一種測試置換對象時,它將被用來替代某個指定類的對象。
stub 可以理解為測試樁,它能實現(xiàn)當特定的方法被調(diào)用時,返回一個指定的模擬值。如果你的測試用例需要一個伴生對象來提供一些數(shù)據(jù),可以使用 stub 來取代數(shù)據(jù)源,在測試設置時可以指定返回每次一致的模擬數(shù)據(jù)。
spy 可以理解為偵查,它負責匯報情況,持續(xù)追蹤什么方法被調(diào)用了,以及調(diào)用過程中傳遞了哪些參數(shù)。你能用它來實現(xiàn)測試斷言,比如一個特定的方法是否被調(diào)用或者是否使用正確的參數(shù)調(diào)用。當你需要測試兩個對象間的某些協(xié)議或者關系時會非常有用。
mock 與 spy 類似,但在使用上有些許不同。spy 追蹤所有的方法調(diào)用,并在事后讓你寫斷言,而 mock 通常需要你事先設定期望。你告訴它你期望發(fā)生什么,然后執(zhí)行測試代碼并驗證最后的結(jié)果與事先定義的期望是否一致。
fake 是一個具備完整功能實現(xiàn)和行為的對象,行為上來說它和這個類型的真實對象上一樣,但不同于它所模擬的類,它使測試變得更加容易。一個典型的例子是使用內(nèi)存中的數(shù)據(jù)庫來生成一個數(shù)據(jù)持久化對象,而不是去訪問一個真正的生產(chǎn)環(huán)境的數(shù)據(jù)庫。
實踐中,這些術語常常用起來不同于它們的定義,甚至可以互換。稍后我們在這篇文章中會看到一些庫,它們自認為自己是 "mock 對象框架",但是其實它們也提供 stub 的功能,而且驗證行為的方式也類似于我描述的 "spy" 而不是 "mock"。所以不要太過于陷入這些詞匯的細節(jié);我下這些定義更多的是因為要在高層次上區(qū)分這些概念,并且它對考慮不同類型測試對象的行為會有幫助。
如果你對不同類型的模擬測試對象更多的細節(jié)討論感興趣,Martin Fowler 的文章 "Mocks Aren't Stubs" 被認為是關于這個問題的權威討論。
許多關于模擬對象的討論主要是衍生自 Fowler 的文章的,它們討論了兩種不同類型的程序員,模擬主義者和統(tǒng)計主義者,所寫的測試。
模擬主義的方式是測試對象之間的交互。通過使用模擬對象,你可以更容易地驗證被測對象是否遵循了它與其他類已建立的協(xié)議,使得在正確的時間發(fā)生正確的外部調(diào)用。對于那些使用行為驅(qū)動 (behavior-driven) 的開發(fā)者來說,這種測試可以驅(qū)動出更好的生產(chǎn)代碼,因為你需要明確模擬出特定的方法,這可以幫你設計出在兩個對象之間使用的更優(yōu)雅的API,這種想法與模擬驅(qū)動緊密聯(lián)系在一起。因此模擬主義的測試更偏向于單元級別的測試,而不是完全的端到端 (end-to-end) 測試。
統(tǒng)計主義的方式是不使用模擬對象。這種思路是測試時只測試狀態(tài)而不是行為,因此這種類型的測試更加健壯。使用模擬測試時,如果你更新了實際類的行為,模擬類也需要同步更新;如果你忘了這么做,你可能會遇到測試可以通過但是代碼卻不能正確工作的情況。通過強調(diào)在測試環(huán)境中只使用那些真正的代碼,統(tǒng)計主義的測試可以幫助你減少測試代碼和實現(xiàn)代碼的耦合度,并降低出錯率。這種類型的測試,您可能已經(jīng)猜到,適合于更全面的端到端的測試。
當然,并不是說有兩個對立的程序員學派;你不可能看到模擬主義和統(tǒng)計主義的當街對決。這種分歧是有用的,但是,得認識到 mock 在有些時候是你的工具箱里最好的工具,但是有時候又不是。不同類型的測試適用于不同的任務,并且最高效的測試套件往往是不同測試風格的集合體。仔細考慮你到底想要用單個測試來驗證些什么,這能幫助你找到最合適的測試方式,而且能幫你決定對于當前工作來說,使用模擬測試對象是否是正確的工具。
理論上談起來所有一切都沒什么問題,但讓我們來看一個你需要用到 mock 的真實用例。
讓我們試著測試一個對象,它上面有一個方法,是通過調(diào)用 UIApplication
的 openURL:
方法來打開另外一個應用程序。(這是我在測試我的 IntentKit 庫時遇到的一個真實問題。) 給這個用例寫一個端到端的測試,就算是有可能做到,也是非常困難的,因為 '成功狀態(tài)' 本身導致了應用程序的關閉。自然的選擇是,模擬出一個 UIApplication
對象,并驗證這個模擬對象是否確實調(diào)用了 openURL
方法打開正確的 URL。
假設這個對象有這樣的方法:
@interface AppLinker : NSObject
- (instancetype)initWithApplication:(UIApplication *)application;
- (void)doSomething:(NSURL *)url;
@end
這是一個非常牽強的例子,但是請容忍我一下。在這個例子中,你會注意到我們使用了構造方法進行注入,當我們創(chuàng)建 AppLinker
的對象時將 UIApplication
對象注入到其中。大部分情況下,使用模擬對象要求使用某種形式的依賴注入。如果這個概念對你很陌生,請一定看看本期的 Jon 的文章 中的描述。
OCMockito 是一個非常輕量級的使用模擬對象的庫:
UIApplication *app = mock([UIApplication class]);
AppLinker *linker = [AppLinker alloc] initWithApplication:app];
NSURL *url = [NSURL urlWithString:@"https://google.com"];
[linker doSomething:URL];
[verify(app) openURL:url];
OCMock 是另一個 Objective-C 的模擬對象庫。和 OCMockito 類似,它提供了關于 stub 和 mock 的所有功能,并且包括了你可能需要的一切功能。它比 OCMockito 的功能更強,依賴于你的個人選擇,各有利弊。
在最基本層面上,我們可以使用 OCMock 來重寫出與之前非常類似的測試:
id app = OCMClassMock([UIApplication class]);
AppLinker *linker = [AppLinker alloc] initWithApplication:app];
NSURL *url = [NSURL urlWithString:@"https://google.com"];
[linker doSomething:url];
OCMVerify([app openURL:url]);
這種在你測試后再驗證調(diào)用方法的模擬測試風格被認為是一種 “運行后驗證” 的方式。OCMock 只在最近 3.0 版本后增加了對該功能的支持。同時它也支持老版本的風格,即對期望運行的驗證,在執(zhí)行測試代碼前先設定對測試結(jié)果的期望。最后,你只需要驗證期望和實際結(jié)果是否對應:
id app = OCMClassMock([UIApplication class]);
AppLinker *linker = [AppLinker alloc] initWithApplication:app];
NSURL *url = [NSURL urlWithString:@"https://google.com"];
OCMExpect([app openURL:url]);
[linker doSomething:url];
OCMVerifyAll();
由于 OCMock 也支持對類方法的 stub,你也可以用這種方式來測試,如果 doSomething
方法通過 [UIApplication sharedApplication]
來實現(xiàn)而不是 UIApplication
對象的注入初始化:
id app = OCMClassMock([UIApplication class]);
OCMStub([app sharedInstance]).andReturn(app);
AppLinker *linker = [AppLinker alloc] init];
NSURL *url = [NSURL urlWithString:@"https://google.com"];
[linker doSomething:url];
OCMVerify([app openURL:url]);
你會發(fā)現(xiàn) stub 類方法和 stub 實例方法看起來是一樣的。
對于像這種簡單的用例,你也許不需要這么重量級的模擬對象測試庫。通常,你只需要創(chuàng)建你自己的模擬對象來測試你關心的行為:
@interface FakeApplication : NSObject
@property (readwrite, nonatomic, strong) NSURL *lastOpenedURL;
- (void)openURL:(NSURL *)url;
@end
@implementation FakeApplication
- (void)openURL:(NSURL *)url {
self.lastOpenedURL = url;
}
@end
以下是測試:
FakeApplication *app = [[FakeApplication alloc] init];
AppLinker *linker = [AppLinker alloc] initWithApplication:app];
NSURL *url = [NSURL urlWithString:@"https://google.com"];
[linker doSomething:url];
XCAssertEqual(app.lastOpenedURL, url, @"Did not open the expected URL");
對于類似這個已經(jīng)設計好的例子,就可能會出現(xiàn)這種情況,創(chuàng)造你自己的模擬對象只是增加了很多不必要的樣板,但如果你覺得需要模擬更為復雜的對象交互,那么完全控制模擬對象的行為就會非常有價值。
選擇哪一種方案完全依賴于你的具體測試情況以及你的個人偏好。OCMockito 和 OCMock 都可以通過 CocoaPods 安裝,將它們集成到你現(xiàn)有的測試環(huán)境都非常簡單,但需要注意的是,除非你需要,否則避免新增一些其他的依賴。另外除非真的需要,最好就都創(chuàng)建一些簡單的模擬對象。
在任何形式的測試中你有可能碰到的最大的問題之一是寫的測試和實現(xiàn)代碼耦合過于緊密。測試中一個最重要的關鍵點是降低未來的變化所帶來的成本;如果改變代碼的實現(xiàn)細節(jié)破壞了當前的測試,則這種成本已經(jīng)增加了。也就是說,其實為了最小化由于使用模擬測試所造成不利影響,其實你有很多可以做的。
如果你還沒有使用依賴注入,或許你會需要它。雖然有時候不使用依賴注入來模擬對象也是可以的的 (比如以上面使用 OCMock 模擬類方法),但是通常是不太可能的。即使可能,設置測試所引入的復雜度也可能大于它能帶來的好處。如果你使用依賴注入的話,你會發(fā)現(xiàn)使用 stub 和 mock 方式寫測試要容易的多。
許多有經(jīng)驗的測試人員都會警告你“不要模擬你沒有的東西”,意思是你應該只為你代碼庫本身擁有的對象創(chuàng)建 mock 或 stub,而不是為第三方依賴或一些庫去創(chuàng)建。這里主要有兩個原因,一個是基于實際情況的,一個是更具有哲學性的考慮。
對于你的代碼庫,你對它不同接口的穩(wěn)定性和不穩(wěn)定性大概會有一個感覺,所以你可以通過你的直覺來判斷使用替換測試的方法是不是可能會導致測試過于脆弱。一般來說,你對第三方代碼沒有這樣的把握。為了解決這個問題,一個通用的做法是為第三方代碼創(chuàng)建包裝類來抽象出它的行為。在某些情況下,僅僅是轉(zhuǎn)移復雜性而不是降低復雜性往往是沒什么意義的。但是在一些情況下,你會很經(jīng)常使用你的第三方代碼,這時這就是一個精簡你測試的好方法。你的單元測試能模擬出自定義對象,并使用高層次的集成或功能測試來測試你的包裝類本身。
iOS 和 OS X 開發(fā)世界的唯一性導致了事情稍微復雜一些。我們做的很多事情都依賴于 Apple 的框架,這個框架遠遠超過了其他語言的一些標準庫。雖然 NSUserDefaults
不是一個“你擁有”對象,但是,如果你發(fā)現(xiàn)你有需要把它模擬出來,那就放心去做吧,蘋果不太可能會在未來的 Xcode 的版本中推出打破這個 API 的變化。
另一個不要模擬第三方依賴庫的原因更具哲學性。使用模擬主義風格書寫測試的部分原因是通過這樣的測試能比較容易的找到兩個對象間最清晰可行的接口。但是如果是第三方依賴,你無法對其進行控制;API 協(xié)議中的一些詳細信息已經(jīng)被第三方庫定死了,所以你無法通過測試來通過實驗有效地驗證接口是否有改進的余地。這本身不是問題,但在很多情況下,它降低了模擬測試的效果,直到把模擬測試的優(yōu)點抹殺殆盡。
測試沒有銀彈;基于你的個人傾向和代碼的具體特性,不同的情況下需要使用不同的策略。測試替身可能不適用所有的情況,但它們會是你測試工具箱中一個非常有效的工具。不管你傾向于使用框架在單元測試中模擬出一切,還是只是根據(jù)需要創(chuàng)建你自己的模擬對象,當你思考如何測試你的代碼時,牢記模擬對象是非常有意義。