依赖注入
从一个例子开始,比如说写了这样一个方法:
- (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] 的调用结果了。