从一个例子开始,比如说写了这样一个方法:
- (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
- (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了:直接替换类方法,让其返回你所期望的返回值。
确实可以用于 DI。在以上的对 DI 形式的简单介绍后,我们会对它们各自的优缺点做进一步的对比分析,请大家继续阅读。
抽取和重写调用
最后要说的这个技术点不在 Seemann 书中所涉及的 DI 形式讨论的范畴。关于抽取和重写调用来自于 Michael Feathers 的Working Effectively With Legacy Code。下面介绍一下如何将这个概念应用到我们的 NSUserDefaults
[NSUserDefaults standardUserDefaults]
[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];
}
妥当完成后,进入最后一步:
测试子类,重写刚刚抽取的方法:
@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"。这篇文章从一个有趣的应用场景出发,由一个日期注入问题引发了一系列关于设计和重用的很详实的讨论。)
环境上下文
如果依赖对象在底层有多处应用,这就极有可能产生横切问题。若继续将依赖对象向上层传递,尤其是还无法预知这个对象将会在什么时候使用的话,便会促生干扰代码。举几个可能产生这样问题的例子:
- 日志处理(Logging)
[NSUserDefaults standardUserDefaults]
[NSDate date]
tearDown
或者 afterEach
尽量不要自己去 swizzling,推荐使用那些现成的、专注于解决与你要处理的问题类似的环境上下文的库。比如:
- 网络 -- Nocilla 或 OHHTTPStubs
- NSDate --TUDelorean
抽取和重写调用
鉴于抽取和重写调用的方法使用简单,效果强大,你可能会采取能用则用的态度。但是由于这种方式需要配备特定的测试子类,这样就会相应的增加了测试的脆弱性。
因为可以避免对依赖对象的调用点进行修改,通常来说,这种方式对有点年头的代码非常有效。
FAQ
“该用哪种 DI 框架?”
我对那些刚开始使用 mock 对象的朋友们的建议是应尽量避免使用 mock 框架,这样你会对各个步骤和细节有更好的理解。同样地,我建议那些刚开始使用 DI 的朋友也不要使用任何 DI 框架。在不依靠 DI 框架的情况下,对 DI 的理解会更纯粹,会更明白自己该做什么,怎么做。
它就是Interface Builder。IB其实不仅仅是UI设计器,任何属性都可以通过将其声明为 IBOutlets 来赋值。当通过 IB 创建的 View 初始化的时候,可以一并创建通过 IB 声明的 Object (因为利用 IB 不仅可以创建 UI 对象还可以创建 “Object” (NSObject 类型,代表 icon 是个纯黄色立方体),那么当 IB 文件初始化的时候,IB 上定义的对象也会随之初始化)。2009年,Eric Smith 在其文章 “Dependency Inversion Principle and iPhone” 中将 Interface Builder 称为是自己 “一直以来最偏爱的 DI 框架”,文中同时给出了如何用 Interface Builder 做依赖注入的例子。
慎选那些需要改变你自己的代码才能使用的框架。比如说继承某个东西,实现某个接口,或者添加什么注解等等,这些都会将你的代码和某种特定实现捆绑在一起 (这与 DI 的根本设计哲学相悖)。反过来,应尽量去用那些不需要浸染你的类即可以从外部连接的框架,至于说是 DSL 方式还是代码方式都无所谓。
“不想公开全部的 Hooks”
对于以上几种公开注入点的注入方式,比如说初始化注入,属性注入,以及方法参数注入等都会让人有一种破坏程序封装性的感觉。我们总有一种想要掩盖依赖注入衔接点的想法,这是可以理解的,因为我们知道这些衔接点是专门为单元测试准备的,它们并不属于 API 业务范畴。所以可以将它们声明在 category 中,并且放到一个单独的头文件里。以上面的 Example.h 为例,再添加一个单独的头文件 ExampleInternal.h。这个头文件只会被 Example.m 和相应的测试代码引用。
id<foo>
的参数,也就是说需要提供一个满足 Foo
如果觉得公开依赖会很繁冗,先看看是否符合以下的场景:
NSUserDefaults
- 例子来说:假如说基于某种原因,你决定避免使用
NSUserDefaults
- 会怎么样?若将其作为显式依赖对象而不是某个内部的实现细节,你是否会觉得这是一个需要检查整个组件的信号?你可以想想使用
NSUserDefaults
- 是不是觉得为了达到测试目标不得已公开了许多不应公开的内部实现?首先,需要判断一下基于你的代码中现行所公开的 API 是否能够支撑完成测试代码编写 (快速且确切)。如果不可以,并且你需要去操作那些本来是隐式的依赖对象,这说明很可能有其他的类也需要操作它。所以应该大胆将其抽象,把它当作依赖对象使用并进行单独测试。
DI 不仅仅是测试
代码组成了模块,模块拼接构建成了应用本身。
使用这种方法有很多益处。Graham Lee 在文章 "Dependency Injection, iOS and You" 中这样描述:“适应新需求,解决bug,增加新功能,单独测试组件。”
可插拔模块牢记吧。它会影响你的很多设计决策,并且引导你去理解更多的 DI 模式和原则。