与Swift编译器配对存根

通常,我们需要对带有副作用的代码进行单元测试,这些副作用通过抽象与Swift中的协议与系统的其他部分进行交互。 有很多方法可以验证它是否按预期工作:打桩,监视,伪造等。总的来说,工程师将这些形式称为模拟 ,即使“ test double”也是合法名称。

仅供参考,“测试双倍”的名称来自电影行业中的“特技双倍”。

在Swift中编写测试双打(例如存根或模拟)是一个无聊的过程,开发人员经常尝试使用Sourcery / SwiftyMocky之类的工具自动生成它。 但是,如果您希望进行更精细的控制,或者不想在构建过程中引入新的步骤,则可以使用一个技巧来快速(至少更快)实现双重测试。

如果您知道存根和模拟的工作原理,则可以轻松地跳过以下两章,跳至下一章或直接跳至解决方案。

首先,让我们区分存根和模拟之间的区别是什么,因为它们非常相似。 根本区别在于验证过程:

  • 对于存根,测试用例必须手动调用一些方法以验证是否发生了预期的副作用
  • 模拟使用预定义的配置自动进行验证

让我们用伪swift代码进行比较:

CounterStub相反, CounterMock是在初始化期间进行预配置的,在CounterStub中,在验证步骤中,我们需要将一些addCalledTimes属性与3进行显式比较。实现这两个测试对addCalledTimes的方式是实现细节。 一般的经验法则是, 验证可以区分我们是使用存根还是模拟(或混合混合)。

让我们先不进行理论讨论,然后再回到地面。 存根实际上如何在Swift中实现? 我们想要创建一个符合协议的对象,并有机会1)验证是否已使用期望的参数调用了给定的函数,以及2)控制函数返回了值。
有许多方法可以做到这一点,最简单的方法就是引入想要跟踪到存根中的行为一样多的属性。 例如,如果要在给定的时间调用该函数,请添加一个计数器,在函数中对其进行递增,然后在测试后验证其值-如上面的代码段所示。
但是,一种替代方法是添加一个附加变量,变量提供一个函数占位符 (具有与该函数几乎相同的签名类型),并在遵守协议时调用它。 我写这本书几乎是因为如果测试用例完全希望忽略给定的功能,我们将其设为可选只是为了方便。

在此博客文章中,我们将讨论协议功能,因为变量相对于存根而言相对简单-仅通过引入实例即可。

为了这篇文章的缘故,让我们考虑一下最简单的协议,该协议将用户名同步保存到数据库中,并返回一个布尔值来通知过程是否成功。 它的模拟看起来像这样:

对于函数addUser ,我们引入了一个变量addUserAction -每次调用addUseraddUser对其求值的函数。 然后,在测试场景中,您可以1)验证被测系统(测试代码)确实调用了该函数,并且2)模拟数据库的响应方式(无论返回true还是false ):

一般而言,通过引入与函数参数和返回类型匹配的addUserAction变量,我们让测试用例定义协议相关函数的主体。

顺便说一句,如果您不喜欢addUserAction属性的可选性,则可以继续执行等效的实现:

在上面显示的存根中,我们必须显式提供一种addUserAction变量。 对于短函数来说,这似乎是一件容易的事,但对于涉及多个参数,闭包, (re)throws@autoclosure ,最终可能会导致您和编译器之间的较量。 为了克服这种麻烦,我们可以利用Swift的类型推断系统。 解决方案涉及一个全局函数,该函数返回与参数类型相同的nil值:

起初,它看起来像一个胡说八道的函数,但我们只会用它来拉出T类型,而不是它的值。

函数是Swift中的一等公民,因此我们可以将函数作为参数传递给asNil以获取Wrapped类型与函数类型匹配的nil实例。 在下面的示例中,我们将变量addUserAction初始化为与协议功能相同类型的nil值。 那正是我们以前必须手动写下的内容:

需要将此变量标记为 lazy 因为引用 addUser 隐式依赖于 self ,在完全初始化之前无法访问 self

我们的Database协议非常简单,因此收益并不是那么可观,但是对于极其复杂的功能,收益却是可观的。 像这儿:

这项技术的另一个好处是,在协议的类型修改的情况下,我们完全不必接触存根的实现-编译器将自动更新类型。

我们的开发人员喜欢将精力集中在一个实际的问题上,而不是将时间花在无聊的和虚拟的任务上,例如实施测试双打。 幸运的是,Swift的静态类型分析器提供了一些工具-我们可以利用它来自动推断单元测试存根实现中使用的属性类型。 然后,在几秒钟内,我们就可以准备一个存根,在测试用例中灵活,完全可自定义了。


我要感谢MateuszMaćkowiak激励我写这篇文章。

编辑(11月16日):

有人写信给我,描述的方法有一个缺点:单元测试不能显式验证产生了哪些副作用,而且当测试用例未指定具体的xxxAction实现时,它需要提供一个后备返回值( ?? false部分)。
我同意,这两个论点都是正确的,因此,如果您也有同样的担忧,让我提出一种替代方法:

代替asNil函数,我们将其替换为stub(of:) ,其行为类似于隐式解开的T

从Swift 4.1开始, ImplicitlyUnwrappedOptional 已重新实现, 但如果您仍然记得,可以想到 stub(of:) 返回 T!

如果在addUserAction中初始化addUserAction之前调用addUser(name:) ,则运行时单元测试会崩溃。 这样做的好处是:1)您可以完全控制允许调用哪些副作用(对存根的任何非预期调用都会使测试崩溃/失败),以及2)您无需指定后备返回值。

现在,您可以选择。 如果您愿意:

  • 在单元测试中永不崩溃->使用asNil方法
  • 完全控制副作用->使用stub(of:)