置換測試: Mock, Stub 和其他,置換測試mockstub
簡介
在理想情況下,你所做的所有測試都是能應對你實際代碼的進階測試。例如,UI 測試將類比實際的使用者輸入(Klaas 在他的文章中有討論)等等。實但際上,這並非永遠都是個好主意。為每個測試案例都訪問一次資料庫或者旋轉一次 UI 會使你的測試跑得非常慢,這會降低你的生產力,並導致你不去經常跑那些測試。若你測試的某段代碼依賴於網路連接,這會要求你的測試環境具備網路接入條件,而且這也難以類比某些特殊的測試,比如當電話處于飛行模式情況下的時候。
正因如此,我們可以用一些類比代碼替換你的實際代碼來編寫一些測試案例。
什麼時候你會需要用到一些類比 (mock) 對象呢?
讓我們從以下這些不同類型的類比對象的基本定義開始。
double 可以理解為置換,它是所有類比測試對象的統稱,我們也可以稱它為替身。一般來說,當你建立任意一種測試置換對象時,它將被用來替代某個指定類的對象。
stub 可以理解為測試樁,它能實現當特定的方法被調用時,返回一個指定的類比值。如果你的測試案例需要一個伴生對象來提供一些資料,可以使用 stub 來取代資料來源,在測試設定時可以指定返回每次一致的類比資料。
spy 可以理解為偵查,它負責彙報情況,持續追蹤什麼方法被調用了,以及調用過程中傳遞了哪些參數。你能用它來實現測試斷言,比如一個特定的方法是否被調用或者是否使用正確的參數調用。當你需要測試兩個對象間的某些協議或者關係時會非常有用。
mock 與 spy 類似,但在使用上有些許不同。spy 追蹤所有的方法調用,並在事後讓你寫斷言,而 mock 通常需要你事先設定期望。你告訴它你期望發生什麼,然後執行測試代碼並驗證最後的結果與事先定義的期望是否一致。
fake 是一個具備完整功能實現和行為的對象,行為上來說它和這個類型的真實對象上一樣,但不同於它所類比的類,它使測試變得更加容易。一個典型的例子是使用記憶體中的資料庫來產生一個資料持久化對象,而不是去訪問一個真正的生產環境的資料庫。
實踐中,這些術語常常用起來不同於它們的定義,甚至可以互換。稍後我們在這篇文章中會看到一些庫,它們自認為自己是 “模擬物件架構”,但是其實它們也提供 stub 的功能,而且驗證行為的方式也類似於我描述的 “spy” 而不是 “mock”。所以不要太過於陷入這些詞彙的細節;我下這些定義更多的是因為要在高層次上區分這些概念,並且它對考慮不同類型測試對象的行為會有協助。
如果你對不同類型的類比測試對象更多的細節討論感興趣,Martin Fowler 的文章 “Mocks Aren’t Stubs” 被認為是關於這個問題的權威討論。
類比主義者 (Mockists) vs. 統計主義者 (Statists)
許多關於類比對象的討論主要是衍生自 Fowler 的文章的,它們討論了兩種不同類型的程式員,類比主義者和統計主義者,所寫的測試。
類比主意的方式是測試對象之間的互動。通過使用類比對象,你可以更容易地驗證被測對象是否遵循了它與其他類已建立的協議,使得在正確的時間發生正確的外部調用。對於那些使用行為驅動 (behavior-driven) 的開發人員來說,這種測試可以驅動出更好的生產代碼,因為你需要明確類比出特定的方法,這可以幫你設計出在兩個對象之間使用的更優雅的API,這種想法與類比驅動緊密聯絡在一起。因此類比主義的測試更偏向於單元層級的測試,而不是完全的端到端 (end-to-end) 測試。
統計主義的方式是不使用類比對象。這種思路是測試時只測試狀態而不是行為,因此這種類型的測試更加健壯。使用類比測試時,如果你更新了實際類的行為,類比類也需要同步更新;如果你忘了這麼做,你可能會遇到測試可以通過但是代碼卻不能正確工作的情況。通過強調在測試環境中只使用那些真正的代碼,統計主意的測試可以協助你減少測試代碼和實現代碼的耦合度,並降低出錯率。這種類型的測試,您可能已經猜到,適合於更全面的端到端的測試。
當然,並不是說有兩個對立的程式員學派;你不可能看到類比主義和統計主義的當街對決。這種分歧是有用的,但是,得認識到 mock 在有些時候是你的工具箱裡最好的工具,但是有時候又不是。不同類型的測試適用於不同的任務,並且最高效的測試套件往往是不同測試風格的集合體。仔細考慮你到底想要用單個測試來驗證些什麼,這能協助你找到最合適的測試方式,而且能幫你決定對於當前工作來說,使用類比測試對象是否是正確的工具。
深入代碼
理論上談起來所有一切都沒什麼問題,但讓我們來看一個你需要用到 mock 的真實用例。
讓我們試著測試一個對象,它上面有一個方法,是通過調用 UIApplication
的 openURL:
方法來開啟另外一個應用程式。(這是我在測試我的 IntentKit 庫時遇到的一個真實問題。) 給這個用例寫一個端到端的測試,就算是有可能做到,也是非常困難的,因為 ‘成功狀態’ 本身導致了應用程式的關閉。自然的選擇是,類比出一個 UIApplication
對象,並驗證這個類比對象是否確實調用了 openURL
方法開啟正確的 URL。
假設這個對象有這樣的方法:
@interface AppLinker : NSObject - (instancetype)initWithApplication:(UIApplication *)application; - (void)doSomething:(NSURL *)url;@end
這是一個非常牽強的例子,但是請容忍我一下。在這個例子中,你會注意到我們使用了構造方法進行注入,當我們建立 AppLinker
的對象時將 UIApplication
對象注入到其中。大部分情況下,使用類比對象要求使用某種形式的依賴注入。如果這個概念對你很陌生,請一定看看本期的 Jon 的文章 中的描述。
OCMockito
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
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]);
這種在你測試後再驗證調用方法的類比測試風格被認為是一種 “運行後驗證” 的方式。OCMock 只在最近 3.0 版本後增加了對該功能的支援。同時它也支援老版本的風格,即對期望啟動並執行驗證,在執行測試代碼前先設定對測試結果的期望。最後,你只需要驗證期望和實際結果是否對應:
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();
Because OCMock lets you stub out class methods, you could also test this using OCMock, if your implementation of doSomething
uses [UIApplication sharedApplication]
rather than the UIApplication
object injected in the initializer: 由於 OCMock 也支援對類方法的 stub,你也可以用這種方式來來測試,如果 doSomething
方法通過 [UIApplication sharedApplication]
來實現而不是 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]);
你會發現 stub 類方法和 stub 執行個體方法看起來是一樣的。
構建你自己的測試
對於像這種簡單的用例,你也許不需要這麼重量級的類比對象測試庫。通常,你只需要建立你自己的類比對象來測試你關心的行為:
@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");
對於類似這個已經設計好的例子,就可能會出現這種情況,創造你自己的類比對象只是增加了很多不必要的樣板,但如果你覺得需要類比更為複雜的對象互動,那麼完全控制類比對象的行為就會非常有價值。
使用哪一個?
選擇哪一種方案完全依賴於你的具體測試情況以及你的個人偏好。OCMockito 和 OCMock 都可以通過 CocoaPods 安裝,將它們整合到你現有的測試環境都非常簡單,但需要注意的是,除非你需要,否則避免新增一些其他的依賴。另外除非真的需要,最好就都建立一些簡單的類比對象。
類比測試時的注意事項
在任何形式的測試中你有可能碰到的最大的問題之一是寫的測試和實現代碼耦合過於緊密。測試中一個最重要的關鍵點是降低未來的變化所帶來的成本;如果改變代碼的實現細節破壞了當前的測試,則這種成本已經增加了。也就是說,其實為了最小化由於使用類比測試所造成不利影響,其實你有很多可以做的。
依賴注入是你的好夥伴
如果你還沒有使用依賴注入,或許你會需要它。雖然有時候不使用依賴注入來類比對象也是可以的的 (比如以上面使用 OCMock 類比類方法),但是通常是不太可能的。即使可能,設定測試所引入的複雜度也可能大於它能帶來的好處。如果你使用依賴注入的話,你會發現使用 stub 和 mock 方式寫測試要容易的多。
不要類比你沒有的
許多有經驗的測試人員都會警告你“不要類比你沒有的東西”,意思是你應該只為你程式碼程式庫本身擁有的對象建立 mock 或 stub,而不是為第三方依賴或一些庫去建立。這裡主要有兩個原因,一個是基於實際情況的,一個是更具有哲學性的考慮。
對於你的程式碼程式庫,你對它不同介面的穩定性和不穩定性大概會有一個感覺,所以你可以通過你的直覺來判斷使用替換測試的方法是不是可能會導致測試過於脆弱。一般來說,你對第三方代碼沒有這樣的把握。為瞭解決這個問題,一個通用的做法是為第三方代碼建立封裝類來抽象出它的行為。在某些情況下,僅僅是轉移複雜性而不是降低複雜性往往是沒什麼意義的。但是在一些情況下,你會很經常使用你的第三方代碼,這時這就是一個精簡你測試的好方法。你的單元測試能類比出自訂對象,並使用高層次的整合或功能測試來測試你的封裝類本身。
iOS 和 OS X 開發世界的唯一性導致了事情稍微複雜一些。我們做的很多事情都依賴於 Apple 的架構,這個架構遠遠超過了其他語言的一些標準庫。雖然 NSUserDefaults
不是一個“你擁有”對象,但是,如果你發現你有需要把它類比出來,那就放心去做吧,蘋果不太可能會在未來的 Xcode 的版本中推出打破這個 API 的變化。
另一個不要類比第三方依賴庫的原因更具哲學性。使用類比主義風格書寫測試的部分原因是通過這樣的測試能比較容易的找到兩個對象間最清晰可行的介面。但是如果是第三方依賴,你無法對其進行控制;API 協議中的一些詳細資料已經被第三方庫定死了,所以你無法通過測試來通過實驗有效地驗證介面是否有改進的餘地。這本身不是問題,但在很多情況下,它降低了類比測試的效果,直到把類比測試的優點抹殺殆盡。
不要模仿我!
測試沒有銀彈;基於你的個人傾向和代碼的具體特性,不同的情況下需要使用不同的策略。測試替身可能不適用所有的情況,但它們會是你測試載入器箱中一個非常有效工具。不管你傾向於使用架構在單元測試中類比出一切,還是只是根據需要建立你自己的類比對象,當你思考如何測試你的代碼時,牢記類比對象是非常有意義。