阅读 618

可测性提升iOS依赖注入Dependency Injection Objective-C

问题:

先看一份代码:

- (NSNumber *)nextReminderId {
    NSNumber *currentReminderId = [[NSUserDefaults standardUserDefaults] objectForKey:@"currentReminderId"];
    if (currentReminderId) {
        // Increment the last reminderId
        currentReminderId = @([currentReminderId intValue] + 1);
    } else {
        // Set to 0 if it doesn't already exist
        currentReminderId = @0;
    }
    // Update currentReminderId to model
    [[NSUserDefaults standardUserDefaults] setObject:currentReminderId forKey:@"currentReminderId"];

    return currentReminderId;
}
复制代码

如何对这份代码进行测试?问题在于这个方法使用NSUserDefaults,这是不受我们控制的。

这里不是简单讨论“如何测试一个使用了NSUserDefaults的方法?",而是讨论“如果一个方法依赖于另一个对象,而该对象会降低测试的快速性和可重复性,那么我们如何测试该方法?

刚开始写单测,遇到的最大阻碍之一是:不知道怎么管理被测试方法的依赖项,不过有很多解决这个问题的方法,这些方法被称为依赖注入(dependency injection, DI)。

依赖注入(DI)的形式

对于DI,最开始会想到依赖注入框架或者控制反转(Inversion of Control,IoC)容器,但这里讨论的稍有差异。

有各种技术可以获取依赖项,并注入其他内容。对于Objective-C runtime,swizzling技术就可以做到这件事。这时候会觉得本文其余的讨论没有必要。但我们还是希望代码的依赖项可以显式化,我们可以看到依赖项(当依赖项太多或者错误的时候,就必须要处理代码的“坏味道”了)。

下面我们来快速的了解一些DI的形式。

构造器注入(Constructor Injection)

注:Objective-C本身没有构造函数的说法,更熟悉的说法是初始化方法,这里用构造函数这个术语,是因为Constructor Injection是一个标准的DI术语,更容易跨语言理解。

对于构造器注入,就是把依赖项作为构造函数的参数传入,内部存储提供给后续使用:

@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;
}
复制代码

属性注入 (Property Injection)

在属性注入中,nextReminderId 的代码看起来是相同的,引用 self.userDefaults. 但我们没有将依赖项传递给初始值设定项,而是将其设置为可设置的属性:

@interface Example
@property (nonatomic, strong) NSUserDefaults *userDefaults;
- (NSNumber *)nextReminderId;
@end
复制代码

现在,一个 test 可以构造这个对象,然后根据需要设置 userDefaults 属性。但是如果属性没有设置,会发生什么呢?在这种情况下,让我们使用惰性初始化在 getter 中建立一个合理的默认值:

- (NSUserDefaults *)userDefaults {
    if (!_userDefaults) {
        _userDefaults = [NSUserDefaults standardUserDefaults];
    }
    return _userDefaults;
}
复制代码

现在,如果任何调用代码在使用前设置了 userDefaults 属性,self.userDefaults 将使用给定的值。但是如果属性没有设置,那么 self.userDefaults 将使用 [NSUserDefaults standardUserDefaults]

方法注入 (Method Injection)

如果依赖项仅在单个方法中引用,那么我们可以直接将其作为方法参数注入:

- (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 参数与方法注入非常适合。(下面我们讨论每种形式的好处时,将详细介绍这一点。)

环境上下文 (Ambient Context)

当通过类方法(例如 singleton)访问依赖关系时,有两种方法可以通过测试控制该依赖关系:

  • 如果控制单例,则可以公开其属性以控制其状态。
  • 如果摆弄属性是不够的,或者单例不是你可以控制的,那么是时候改变了:替换class方法,以便它返回你需要的伪方法。

我将不深入讨论一个令人眩晕的例子的细节;在这方面还有很多其他的资源。但是看到了吗?Swizzling 可用于DI。不过,请继续读下去。在简要概述了不同形式的DI之后,我们将讨论它们的优缺点。

提取并覆盖调用 (Extract and Override Call)

最后一种技术不属于 Seemann 书中DI的形式。相反,extract 和 override 调用来自于有效地处理 Michael Feathers 的遗留代码。下面介绍如何通过三个步骤将此技术应用于我们的 NSUserDefaults 问题:

  1. 选择其中一个调用 [NSUserDefaults standardUserDefaults]。使用 automated refactoring(在Xcode或AppCode中)将其提取到新方法中。

  1. 更改进行调用的其他位置,将其替换为对新方法的调用。(注意不要更改新方法本身。)

修改后的代码如下所示:

- (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];
}
复制代码

准备就绪后,最后一步是:

  1. 创建一个特殊的测试子类,覆盖提取的方法,如下所示:
@interface TestingExample : Example
@end

@implementation TestingExample

- (NSUserDefaults *)userDefaults {
    // Do whatever you want!
}

@end
复制代码

测试代码现在可以实例化 TestingExample 而不是 Example,并且可以完全控制生产代码调用 [self userDefaults] 时发生的事情。

“那我应该用哪种方法呢?”

我们有五种不同形式的DI。每个都有自己的优缺点,所以每个都有自己的位置。

构造器注入(Constructor Injection)

构造器注入应该是你选择的武器。如果有疑问,从这里开始。优点是它使依赖关系显式化。

缺点是一开始会觉得很麻烦。当初始值设定项有一长串依赖项时尤其如此。但这揭示了一个以前隐藏的代码气味:类是否有太多的依赖关系?也许这不符合单一责任原则

属性注入 (Property Injection)

属性注入的优点是它将初始化和注入分离开来,这在无法更改调用方时是必需的。缺点是,它将初始化和注入分离开来!这使得不完全初始化成为可能。这就是为什么当依赖项有特定的默认值时,或者当您知道依赖项将由DI框架填充时,最好使用它。

属性注入看起来很简单,但要使其健壮却异常棘手:

您可能需要防止属性被任意重置。因此,您可能需要一个自定义的 setter 来代替默认的 setter,以确保返回实例变量为nil,并且给定的参数为非nil。

getter 需要线程安全吗?如果是这样,那么使用构造函数注入会更容易,而不是试图使 getter 既安全又快速。

另外,要注意不要仅仅因为想到了一个特定的实例就自动倾向于属性注入。确保默认值未引用其他库。否则,您将要求您的类的用户也包括其他库,从而打破松散耦合的好处。(在Seemann的术语中,这是本地违约和外部违约之间的区别。)

方法注入 (Method Injection)

当依赖项随每次调用而变化时,方法注入是好的。这可能是关于调用点的特定于应用程序的上下文。可能是随机数。可能是当前时间。

考虑使用当前时间的方法。请尝试向方法中添加 NSDate 参数,而不是直接调用 [NSDate date]。随着调用复杂性的小幅度增加,它为更灵活地使用该方法提供了一些选择。

(虽然 Objective-C 使得在不需要协议的情况下很容易替换测试 double,但我建议阅读J.B.Rainsberger的 “Beyond Mock Objects”。这是一个有趣的例子,说明了如何与注入日期进行斗争,从而打开了更大的设计和重用问题。)

环境上下文 (Ambient Context)

如果您有一个在各种低级点上使用的依赖关系,那么您可能有一个跨领域的问题。通过更高级别传递这种依赖关系可能会干扰代码,尤其是当您无法提前预测需要它的地方时。例如:

  • 登录中
  • [NSUserDefaults standardUserDefaults]
  • [NSDate date]

周围的环境可能正是你所需要的。但因为它会影响全局上下文,所以完成后不要忘记重置它。例如,如果您 swizzle 一个方法,请使用 tearDownafterEach (取决于您的测试框架)来恢复原始方法。

与其自己做 swizzling,不如看看是否有人已经写了一个库,关注你需要的周围环境。例如:

提取并覆盖调用 (Extract and Override Call)

因为 extractoverride 调用非常简单和强大,所以您可能会在任何地方使用它。但是因为它需要特定于测试的子类,所以测试很容易变得脆弱。

也就是说,它对遗留代码非常有效,特别是当您不想更改所有调用方时。

FAQ

"我应该使用哪种框架呢?"

我给那些刚开始使用 mock 对象的人的建议是,首先不要使用任何 mock 对象框架,因为这样你会更好地理解正在发生的事情。我对那些从DI开始的人的建议也是一样的。但是你可以在没有框架的情况下更进一步地使用DI,完全依赖于“Poor Man’s DI,”,你可以自己做。

实际上,很有可能您已经使用了DI框架!它被称为Interface Builder。IB不仅仅是布局接口;通过将这些属性声明为 IBOutlets,可以用真实对象填充任意属性。这对于在创建视图时创建对象图非常有效。在2009年的文章“依赖反转原理和iPhone”中,Eric Smith 称 Interface Builder 为“我最喜欢的DI框架”,给出了如何使用 Interface Builder 进行依赖注入的示例。

如果你决定你需要一个DI框架, Interface Builder 是不够的,你怎么选择一个好的?我的建议是:对任何需要修改代码的框架都要谨慎。一旦您必须对某个对象进行子类化、遵守协议或添加某种注释,您就可以将代码直接绑定到特定的实现。(这与DI背后的基本思想背道而驰!)相反,找到一个框架,让您从类外部指定连接,无论是通过 DSL还是在代码中指定。

“我不想把这些Hook都暴露。”

暴露初始值设定项、属性和方法参数中的注入点可能会让人觉得你在破坏封装。人们希望避免显示接缝,因为很容易告诉自己接缝的存在只是为了支持测试,因此不属于API。这可以通过在一个单独的头文件中的类别中声明它们来实现。例如,如果我们正在处理 Example.h,那么创建一个额外的头 ExampleInternal.h。这将仅由 Example.m 和测试代码导入。

但在您采用这种方法之前,我想对DI导致破坏封装的想法提出质疑。我们正在做的是使依赖关系显式化。我们正在定义组件的边缘,以及它们如何组合在一起。例如,如果一个类有一个参数类型为id<Foo> 的初始值设定项,那么很明显,为了使用该类,需要给它一个满足Foo协议的对象。可以将其视为在类上定义一组 socket,以及与这些 socket 相匹配的 plug。

当暴露依赖项感觉很麻烦时,看看这些场景是否适合:

  • 暴露对苹果对象的依赖性感觉愚蠢吗?难道苹果公司提供的任何东西都是隐含的吗?因此,对于任何代码都是公平的吗?不一定。以我们的 NSUserDefaults 为例:如果您出于某种原因决定避免使用 NSUserDefaults 呢?将其显式标识为依赖项而不是隐藏为实现细节将提醒您调查此组件。您可以检查 NSUserDefaults 的使用是否违反了设计约束。

  • 你是否觉得为了测试你的类,你必须公开一堆内部接口?首先,看看是否可以编写只通过现有公共API的测试(同时仍然是快速和确定的)。如果你不能,如果你需要操纵依赖,否则会被隐藏,有可能是另一个类试图摆脱。提取它,将它转换为依赖项,然后分别测试它。

DI大于测试

我探索DI的最初动机来自于 test-driven development,因为在 TDD 中,您经常会遇到“我如何为此编写单元测试”的问题?“但我发现 DI 实际上关心的是一个更大的想法:我们的代码应该由模块组成,我们可以将这些模块拼合在一起构建一个应用程序。

这种方法有许多好处。Graham Lee的文章 “Dependency Injection, iOS and You” 描述了其中的一些:“为了适应……新的需求,进行bug修复,添加新的特性,并单独测试组件。”

因此,当您开始应用DI来编写单元测试时,请记住上面更重要的想法。把可插拔模块放在脑后。它将为许多设计决策提供信息,并引导您了解更多DI模式和原则。

文章分类
iOS
文章标签