從一個例子開始,比如說寫了這樣一個方法:
- (NSNumber *)nextReminderId
{
NSNumber *currentReminderId = [[NSUserDefaults standardUserDefaults] objectForKey:@"currentReminderId"];
if (currentReminderId) {
// 增加前一個 reminderId
currentReminderId = @([currentReminderId intValue] + 1);
} else {
// 如果還沒有,設(shè)為 0
currentReminderId = @0;
}
// 將 currentReminderId 更新到 model 中
[[NSUserDefaults standardUserDefaults] setObject:currentReminderId forKey:@"currentReminderId"];
return currentReminderId;
}
如何針對這個方法編寫單元測試呢?這里需要注意一點,該方法中操作了一個不屬于其控制的對象NSUserDefaults
。
容我贅述,就這個例子展開說,雖然這里我使用了 NSUserDefaults
,但這背后顯然有一個更大的范疇。這個問題不僅僅是 “如何去測試一個操作了 NSUserDefaults
的方法?”,而可以演化為 “若一個對象對于快速且可重復(fù)的測試有著直接影響,如何對一個依賴這種對象的方法進行單元測試呢?”。
目前此類單元測試的最大障礙是,如何在你想要測試的代碼之外的地方處理這種依賴關(guān)系。依賴注入 (dependency injection,簡稱 DI) 這一范疇內(nèi)就有一系列方法專門用于解決此類問題。
其實一提到 DI,很多人會直接想到依賴注入框架或者是控制反轉(zhuǎn) (Inversion of Control 簡稱 IoC) 容器。請把這些概念都暫且擱置,我會在后面的 FAQ (常見問題) 中做說明。
現(xiàn)行有很多技術(shù)可以處理在依賴中注入某些東西這件事情。比如說 Objective-C runtime 中的 swizzling 就是其一,swizzling 可以在運行時動態(tài)地將方法進行替換。當然也有人提出質(zhì)疑,他們覺得 swizzling 的存在讓 DI 變得無關(guān)緊要,甚至應(yīng)盡量避免使用 DI。但是我更傾向于那些使依賴關(guān)系能夠清晰化的代碼,因為這樣更便于觀察它們 (并且促使我們?nèi)ヌ幚砟切┯捎谝蕾囘^于復(fù)雜而導(dǎo)致的變壞或者錯誤的代碼)。
接下來我們快速了解一下 DI 的形式。其中除一個以外,其他的例子都來自于 Mark Seemann 的 Dependency Injection in .Net
注意:盡管 Objective-C 本身沒有所謂的構(gòu)造器而是使用初始化方法,但因為構(gòu)造器注入是 DI 的標準概念,放到各種語言中也是普遍適用的,所以我還是準備用構(gòu)造器注入這個詞來代指初始化注入。
構(gòu)造器注入,即將某個依賴對象傳入到構(gòu)造器中 (在 Objective- C中指 designated 初始化方法) 并存儲起來,以便在后續(xù)過程中使用:
@interface Example ()
@property (nonatomic, strong, readonly) NSUserDefaults *userDefaults;
@end
@implementation Example
- (instancetype)initWithUserDefaults:(NSUserDefaults *userDefaults)
{
self = [super init];
if (self) {
_userDefaults = userDefaults;
}
return self;
}
@end
可以用實例變量或者是屬性來存儲依賴對象。上面的例子中用一個只讀的屬性來存儲,防止依賴對象被篡改。
對 NSUserDefaults
進行注入看起來會比較怪,這可能也是這個例子的不足之處。注意,NSUserDefaults
作為依賴對象,臉上就寫著 “麻煩制造者” 這幾個字。其實被注入的更應(yīng)該是一個抽象類型的對象 (像 idNSUserDefaults
來說明。
至此,這個類中每一處要使用單例 [NSUserDefaults standardUserDefaults]
的地方,都應(yīng)該用 self.userDefaults
來替代:
- (NSNumber *)nextReminderId
{
NSNumber *currentReminderId = [self.userDefaults objectForKey:@"currentReminderId"];
if (currentReminderId) {
currentReminderId = @([currentReminderId intValue] + 1);
} else {
currentReminderId = @0;
}
[self.userDefaults setObject:currentReminderId forKey:@"currentReminderId"];
return currentReminderId;
}
對于屬性注入,nextReminderId
的代碼看起來和 self.userDefaults
的做法是一致的。只是這次不是將依賴對象傳遞給初始化方法,而是采用屬性賦值方式:
@interface Example
@property (nonatomic, strong) NSUserDefaults *userDefaults;
- (NSNumber *)nextReminderId;
@end
現(xiàn)在可以在單元測試中創(chuàng)建一個對象,然后將需要的東西通過對 userDefaults
屬性進行賦值。但是要是這個屬性沒有被預(yù)先設(shè)定的話要怎么辦呢?這時,我們可以使用 lazy 加載的方法為其設(shè)置一個適當?shù)哪J值,這能保證始終可以通過 getter 拿到一個確切的值:
- (NSUserDefaults *)userDefaults
{
if (!_userDefaults) {
_userDefaults = [NSUserDefaults standardUserDefaults];
}
return _userDefaults;
}
這樣的話,對 userDefaults
來說,如果在使用者取值之前做過賦值操作,那么從 self.userDefaults
得到的就是通過 setter 賦的值。如果這個屬性在使用前未被賦值,從 self.userDefaults
得到的就是 [NSUserDefaults standardUserDefaults]
。
如果依賴對象只在某一個方法中被使用,則可以利用方法參數(shù)做注入:
- (NSNumber *)nextReminderIdWithUserDefaults:(NSUserDefaults *)userDefaults
{
NSNumber *currentReminderId = [userDefaults objectForKey:@"currentReminderId"];
if (currentReminderId) {
currentReminderId = @([currentReminderId intValue] + 1);
} else {
currentReminderId = @0;
}
[userDefaults setObject:currentReminderId forKey:@"currentReminderId"];
return currentReminderId;
}
再一次說明,這樣看起來可能會很奇怪,并不是所有的例子中 NSUserDefaults
作為依賴都顯得恰如其分。比如說這個例子中,如果使用 NSDate
做注入?yún)?shù)傳入可能更會彰顯其特點 (后面對每種注入方式的優(yōu)點做闡述的時候會有更深入的探討)。
當通過一個類方法 (例如單例) 來訪問依賴對象時,在單元測試中可以通過兩種方式來控制依賴對象:
如果可以控制單例本身,則可以通過公開其屬性來控制其狀態(tài)。
這里不會給出具體的 swizzling 的例子;相關(guān)的資源有很多,感興趣的讀者可以自行查找。這邊要說明的就是 swizzling 確實可以用于 DI。在以上的對 DI 形式的簡單介紹后,我們會對它們各自的優(yōu)缺點做進一步的對比分析,請大家繼續(xù)閱讀。
最后要說的這個技術(shù)點不在 Seemann 書中所涉及的 DI 形式討論的范疇。關(guān)于抽取和重寫調(diào)用來自于 Michael Feathers 的 Working Effectively With Legacy Code。下面介紹一下如何將這個概念應(yīng)用到我們的 NSUserDefaults
例子中去,具體分為三步:
步驟 1:隨便找一處對 [NSUserDefaults standardUserDefaults]
的調(diào)用。利用 IDE (Xcode 或者 AppCode) 的自動重構(gòu)功能將其抽取成一個新的方法。
步驟 2:將其他所有對 [NSUserDefaults standardUserDefaults]
的調(diào)用均替換成步驟 1 中抽取的方法 (注意不要把已抽取的方法中的 [NSUserDefaults standardUserDefaults]
替換成方法自身,囧)。
修改后的代碼如下:
- (NSNumber *)nextReminderId
{
NSNumber *currentReminderId = [[self userDefaults] objectForKey:@"currentReminderId"];
if (currentReminderId) {
currentReminderId = @([currentReminderId intValue] + 1);
} else {
currentReminderId = @0;
}
[[self userDefaults] setObject:currentReminderId forKey:@"currentReminderId"];
return currentReminderId;
}
- (NSUserDefaults *)userDefaults
{
return [NSUserDefaults standardUserDefaults];
}
妥當完成后,進入最后一步:
步驟 3:創(chuàng)建一個專門的測試子類,重寫剛剛抽取的方法:
@interface TestingExample : Example
@end
@implementation TestingExample
- (NSUserDefaults *)userDefaults
{
// Do whatever you want!
}
@end
這樣就不再初始化 Example
,而是利用創(chuàng)建 TestingExample
來進行測試,至此就可以全權(quán)掌控任何對 [self userDefaults]
的調(diào)用結(jié)果了。
現(xiàn)在,一共提到了五種 DI 的形式。每一種都有其自身的優(yōu)缺點和適用場景。
基本上,構(gòu)造器注入應(yīng)該作為首選武器存在。其優(yōu)勢就是讓所涉及的依賴非常清晰。
其缺點便是,乍一看會給人一種非常笨重的感覺。當初始化方法包含了一大堆依賴對象作為參數(shù)的時候尤甚。但這恰巧揭示了前文所提及的腐爛代碼味道的問題:這個類的依賴對象是否也太多了些?它可能已經(jīng)違背了單一職能原則。
屬性注入的長處是將初始化與注入分離,這在不能改變調(diào)用者部分的時候非常有用。那么它的劣勢又是什么呢?還是將初始化與注入分離!是的,你沒看錯。屬性注入使得初始化不充分。它的最佳應(yīng)用場景是在依賴對象有默認值時,換句話說就是確知依賴對象可以在某個時點被 DI 框架賦值。
屬性注入看似容易,但實則不然,特別是如果我們想將其實現(xiàn)得可靠的話:
由于人們經(jīng)常會對特定實例存在著固有認識,所以還應(yīng)盡量避免潛意識中對使用屬性注入的傾向性。另外,請確定默認值不會引用到其他庫的代碼。否則,當前的類的使用者還必須得去引用對應(yīng)的庫,這樣的設(shè)計就違背了松耦合原則 (用 Seemann 的概念來解釋就是,這屬于內(nèi)部默認和外部默認的區(qū)別)。
假如所依賴的對象針對每次調(diào)用都會有所不同的話,使用方法注入會比較好。一個例子是對調(diào)用點來說,可能會涉及到特定應(yīng)用上下文條件的時候,比如基于一個隨機數(shù),或者是當前時間等。
好比一個方法依賴于當前時間。不建議直接調(diào)用 [NSDate date]
,最好在這個方法中增加一個 NSDate
參數(shù)。這么做也許會增加一點點的調(diào)用復(fù)雜度,但是方法的靈活性得到了增強。
(雖然對 Objective-C 來說,不需要使用 procotols 也能很好的利用測試置換來做重復(fù)性測試,但我還是推薦大家閱讀一下 J.B. Rainsberger 的"Beyond Mock Objects"。這篇文章從一個有趣的應(yīng)用場景出發(fā),由一個日期注入問題引發(fā)了一系列關(guān)于設(shè)計和重用的很詳實的討論。)
如果依賴對象在底層有多處應(yīng)用,這就極有可能產(chǎn)生橫切問題。若繼續(xù)將依賴對象向上層傳遞,尤其是還無法預(yù)知這個對象將會在什么時候使用的話,便會促生干擾代碼。舉幾個可能產(chǎn)生這樣問題的例子:
[NSUserDefaults standardUserDefaults]
[NSDate date]
這類場景下推薦適用環(huán)境上下文方式。由于是影響全局的上下文,使用完畢后,別忘了要將其還原。比如你用 swizzle 替換了一個方法,需要在 tearDown
或者 afterEach
(取決于所使用的測試框架) 中對被替換的原始方法進行還原。
盡量不要自己去 swizzling,推薦使用那些現(xiàn)成的、專注于解決與你要處理的問題類似的環(huán)境上下文的庫。比如:
鑒于抽取和重寫調(diào)用的方法使用簡單,效果強大,你可能會采取能用則用的態(tài)度。但是由于這種方式需要配備特定的測試子類,這樣就會相應(yīng)的增加了測試的脆弱性。
因為可以避免對依賴對象的調(diào)用點進行修改,通常來說,這種方式對有點年頭的代碼非常有效。
我對那些剛開始使用 mock 對象的朋友們的建議是應(yīng)盡量避免使用 mock 框架,這樣你會對各個步驟和細節(jié)有更好的理解。同樣地,我建議那些剛開始使用 DI 的朋友也不要使用任何 DI 框架。在不依靠 DI 框架的情況下,對 DI 的理解會更純粹,會更明白自己該做什么,怎么做。
事實上,很可能不知不覺中你已經(jīng)在使用 DI 框架了!它就是Interface Builder。IB其實不僅僅是UI設(shè)計器,任何屬性都可以通過將其聲明為 IBOutlets 來賦值。當通過 IB 創(chuàng)建的 View 初始化的時候,可以一并創(chuàng)建通過 IB 聲明的 Object (因為利用 IB 不僅可以創(chuàng)建 UI 對象還可以創(chuàng)建 “Object” (NSObject 類型,代表 icon 是個純黃色立方體),那么當 IB 文件初始化的時候,IB 上定義的對象也會隨之初始化)。2009年,Eric Smith 在其文章 “Dependency Inversion Principle and iPhone” 中將 Interface Builder 稱為是自己 “一直以來最偏愛的 DI 框架”,文中同時給出了如何用 Interface Builder 做依賴注入的例子。
如果你覺得 Interface Builder 還不足以應(yīng)付 DI 工作,還需要使用其他 DI 框架,怎么選擇才合適呢?我的建議是:慎選那些需要改變你自己的代碼才能使用的框架。比如說繼承某個東西,實現(xiàn)某個接口,或者添加什么注解等等,這些都會將你的代碼和某種特定實現(xiàn)捆綁在一起 (這與 DI 的根本設(shè)計哲學(xué)相悖)。反過來,應(yīng)盡量去用那些不需要浸染你的類即可以從外部連接的框架,至于說是 DSL 方式還是代碼方式都無所謂。
對于以上幾種公開注入點的注入方式,比如說初始化注入,屬性注入,以及方法參數(shù)注入等都會讓人有一種破壞程序封裝性的感覺。我們總有一種想要掩蓋依賴注入銜接點的想法,這是可以理解的,因為我們知道這些銜接點是專門為單元測試準備的,它們并不屬于 API 業(yè)務(wù)范疇。所以可以將它們聲明在 category 中,并且放到一個單獨的頭文件里。以上面的 Example.h 為例,再添加一個單獨的頭文件 ExampleInternal.h。這個頭文件只會被 Example.m 和相應(yīng)的測試代碼引用。
在采納這個方法去實踐之前,我還要討論一下關(guān)于 DI 會導(dǎo)致破壞程序封裝原則的這個問題。其實 DI 的目標就是讓依賴更加明顯。我們界定了組件的邊界和它們之間的組裝方式。舉例來說,如果一個類的某個初始化方法中含有一個類型為 id<foo>
的參數(shù),也就是說需要提供一個滿足 Foo
接口的對象方可初始化這個類。好比說如果你在某個類中定義了一組插座,同時也要為其匹配相應(yīng)的插頭。
如果覺得公開依賴會很繁冗,先看看是否符合以下的場景:
公開對 Apple 對象的依賴是不是不太好?Apple 提供的東西不就等于暗示是可用的嗎,對于其他的代碼是不是也應(yīng)同等對待?其實不然!還是拿我們的 NSUserDefaults
例子來說:假如說基于某種原因,你決定避免使用 NSUserDefaults
會怎么樣?若將其作為顯式依賴對象而不是某個內(nèi)部的實現(xiàn)細節(jié),你是否會覺得這是一個需要檢查整個組件的信號?你可以想想使用 NSUserDefaults
是否真的違反了你在設(shè)計上的約束。
我最初決定鉆研DI是因為在執(zhí)行測試驅(qū)動開發(fā) (TDD),而在 TDD 的過程中有一個很糾結(jié)的問題會時常跳出來:“對于這個實現(xiàn),如何編寫單元測試?”。后來我發(fā)現(xiàn)其實 DI 本身是在彰顯一個更高層面的概念:代碼組成了模塊,模塊拼接構(gòu)建成了應(yīng)用本身。
使用這種方法有很多益處。Graham Lee 在文章 "Dependency Injection, iOS and You" 中這樣描述:“適應(yīng)新需求,解決bug,增加新功能,單獨測試組件?!?/p>
所以當我們在編寫單元測試的時候用到 DI,應(yīng)該回想一下上文所提到的更高層面的概念。請將可插拔模塊牢記吧。它會影響你的很多設(shè)計決策,并且引導(dǎo)你去理解更多的 DI 模式和原則。