依赖注入
从一个例子开始,比如说写了这样一个方法:
- (NSNumber *)nextReminderId
{
NSNumber *currentReminderId = [[NSUserDefaults standardUserDefaults] objectForKey:@"currentReminderId"];
if (currentReminderId) {
// 增加前一个 reminderId
currentReminderId = @([currentReminderId intValue] + 1);
} else {
// 如果还没有,设为 0
currentReminderId = @0;
}
// 将 currentReminderId 更新到 model 中
[[NSUserDefaults standardUserDefaults] setObject:currentReminderId forKey:@"currentReminderId"];
return currentReminderId;
}
如何针对这个方法编写单元测试呢?这里需要注意一点,该方法中操作了一个不属于其控制的对象NSUserDefaults。
容我赘述,就这个例子展开说,虽然这里我使用了 NSUserDefaults,但这背后显然有一个更大的范畴。这个问题不仅仅是 “如何去测试一个操作了 NSUserDefaults 的方法?”,而可以演化为 “若一个对象对于快速且可重复的测试有着直接影响,如何对一个依赖这种对象的方法进行单元测试呢?”。
目前此类单元测试的最大障碍是,如何在你想要测试的代码之外的地方处理这种依赖关系。依赖注入 (dependency injection,简称 DI) 这一范畴内就有一系列方法专门用于解决此类问题。
依赖注入的几种形式
其实一提到 DI,很多人会直接想到依赖注入框架或者是控制反转 (Inversion of Control 简称 IoC) 容器。请把这些概念都暂且搁置,我会在后面的 FAQ (常见问题) 中做说明。
现行有很多技术可以处理在依赖中注入某些东西这件事情。比如说 Objective-C runtime 中的 swizzling 就是其一,swizzling 可以在运行时动态地将方法进行替换。当然也有人提出质疑,他们觉得 swizzling 的存在让 DI 变得无关紧要,甚至应尽量避免使用 DI。但是我更倾向于那些使依赖关系能够清晰化的代码,因为这样更便于观察它们 (并且促使我们去处理那些由于依赖过于复杂而导致的变坏或者错误的代码)。
接下来我们快速了解一下 DI 的形式。其中除一个以外,其他的例子都来自于 Mark Seemann 的 Dependency Injection in .Net
构造器注入
注意:尽管 Objective-C 本身没有所谓的构造器而是使用初始化方法,但因为构造器注入是 DI 的标准概念,放到各种语言中也是普遍适用的,所以我还是准备用构造器注入这个词来代指初始化注入。
构造器注入,即将某个依赖对象传入到构造器中 (在 Objective- C中指 designated 初始化方法) 并存储起来,以便在后续过程中使用:
@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 作为依赖对象,脸上就写着 “麻烦制造者” 这几个字。其实被注入的更应该是一个抽象类型的对象 (像 id 这种) 来作为依赖可能会比指定某个具体类型要更好一些。但本文就不做更多展开了,还是继续以 NSUserDefaults 来说明。
至此,这个类中每一处要使用单例 [NSUserDefaults standardUserDefaults] 的地方,都应该用 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
现在可以在单元测试中创建一个对象,然后将需要的东西通过对 userDefaults 属性进行赋值。但是要是这个属性没有被预先设定的话要怎么办呢?这时,我们可以使用 lazy 加载的方法为其设置一个适当的默认值,这能保证始终可以通过 getter 拿到一个确切的值:
**- (**NSUserDefaults *)userDefaults
{
if (!_userDefaults) {
**_userDefaults = [**NSUserDefaults standardUserDefaults];
}
return _userDefaults;
}
这样的话,对 userDefaults 来说,如果在使用者取值之前做过赋值操作,那么从 self.userDefaults 得到的就是通过 setter 赋的值 。如果这个属性在使用前未被赋值,从 self.userDefaults 得到的就是 [NSUserDefaults standardUserDefaults]。
方法注入
如果依赖对象只在某一个方法中被使用,则可以利用方法参数做注入:
**- (**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 做注入参数传入可能更会彰显其特点 (后面对每种注入方式的优点做阐述的时候会有更深入的探讨)。
环境上下文
当通过一个类方法 (例如单例) 来访问依赖对象时,在单元测试中可以通过两种方式来控制依赖对象:
- 如果可以控制单例本身,则可以通过公开其属性来控制其状态。
- 如果上述方式无效或者所操作的单例不归自己管理,此时就该运用swizzle了:直接替换类方法,让其返回你所期望的返回值。
这里不会给出具体的 swizzling 的例子;相关的资源有很多,感兴趣的读者可以自行查找。这边要说明的就是 swizzling 确实可以用于 DI。在以上的对 DI 形式的简单介绍后,我们会对它们各自的优缺点做进一步的对比分析,请大家继续阅读。
抽取和重写调用
最后要说的这个技术点不在 Seemann 书中所涉及的 DI 形式讨论的范畴。关于抽取和重写调用来自于 Michael Feathers 的 Working Effectively With Legacy Code。下面介绍一下如何将这个概念应用到我们的 NSUserDefaults 例子中去,具体分为三步:
步骤 1:随便找一处对 [NSUserDefaults standardUserDefaults] 的调用。利用 IDE (Xcode 或者 AppCode) 的自动重构功能将其抽取成一个新的方法。
步骤 2:将其他所有对 [NSUserDefaults standardUserDefaults] 的调用均替换成步骤 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:创建一个专门的测试子类,重写刚刚抽取的方法:
@interface TestingExample : Example
@end
*@implementation* *TestingExample*
**- (**NSUserDefaults *)userDefaults
{
// Do whatever you want!
}
@end
这样就不再初始化 Example,而是利用创建 TestingExample 来进行测试,至此就可以全权掌控任何对 [self userDefaults] 的调用结果了。
“究竟该选择使用哪种形式?”
现在,一共提到了五种 DI 的形式。每一种都有其自身的优缺点和适用场景。
构造器注入
基本上,构造器注入应该作为首选武器存在。其优势就是让所涉及的依赖非常清晰。
其缺点便是,乍一看会给人一种非常笨重的感觉。当初始化方法包含了一大堆依赖对象作为参数的时候尤甚。但这恰巧揭示了前文所提及的腐烂代码味道的问题:这个类的依赖对象是否也太多了些?它可能已经违背了单一职能原则。
属性注入
属性注入的长处是将初始化与注入分离,这在不能改变调用者部分的时候非常有用。那么它的劣势又是什么呢?还是将初始化与注入分离!是的,你没看错。属性注入使得初始化不充分。它的最佳应用场景是在依赖对象有默认值时,换句话说就是确知依赖对象可以在某个时点被 DI 框架赋值。
属性注入看似容易,但实则不然,特别是如果我们想将其实现得可靠的话:
- 必须防范属性被任意重设值。这需要复写系统默认为属性生成的 setter,要保证相应的实例变量为 nil 以及传入的参数不是 nil。
- getter 是否需要线程安全?如果需要,那么与实现需要兼顾效率和线程安全的 getter 相比,使用构造器注入就显得容易多了。
由于人们经常会对特定实例存在着固有认识,所以还应尽量避免潜意识中对使用属性注入的倾向性。另外,请确定默认值不会引用到其他库的代码。否则,当前的类的使用者还必须得去引用对应的库,这样的设计就违背了松耦合原则 (用 Seemann 的概念来解释就是,这属于内部默认和外部默认的区别)。
方法注入
假如所依赖的对象针对每次调用都会有所不同的话,使用方法注入会比较好。一个例子是对调用点来说,可能会涉及到特定应用上下文条件的时候,比如基于一个随机数,或者是当前时间等。
好比一个方法依赖于当前时间。不建议直接调用 [NSDate date],最好在这个方法中增加一个 NSDate 参数。这么做也许会增加一点点的调用复杂度,但是方法的灵活性得到了增强。
(虽然对 Objective-C 来说,不需要使用 procotols 也能很好的利用测试置换来做重复性测试,但我还是推荐大家阅读一下 J.B. Rainsberger 的"Beyond Mock Objects"。这篇文章从一个有趣的应用场景出发,由一个日期注入问题引发了一系列关于设计和重用的很详实的讨论。)