与Swift编译器配对存根
通常,我们需要对带有副作用的代码进行单元测试,这些副作用通过抽象与Swift中的协议与系统的其他部分进行交互。 有很多方法可以验证它是否按预期工作:打桩,监视,伪造等。总的来说,工程师将这些形式称为模拟 ,即使“ test double”也是合法名称。
仅供参考,“测试双倍”的名称来自电影行业中的“特技双倍”。
在Swift中编写测试双打(例如存根或模拟)是一个无聊的过程,开发人员经常尝试使用Sourcery / SwiftyMocky之类的工具自动生成它。 但是,如果您希望进行更精细的控制,或者不想在构建过程中引入新的步骤,则可以使用一个技巧来快速(至少更快)实现双重测试。
如果您知道存根和模拟的工作原理,则可以轻松地跳过以下两章,跳至下一章或直接跳至解决方案。
首先,让我们区分存根和模拟之间的区别是什么,因为它们非常相似。 根本区别在于验证过程:
- 对于存根,测试用例必须手动调用一些方法以验证是否发生了预期的副作用
- 模拟使用预定义的配置自动进行验证
让我们用伪swift代码进行比较:
与CounterStub
相反, CounterMock
是在初始化期间进行预配置的,在CounterStub
中,在验证步骤中,我们需要将一些addCalledTimes
属性与3进行显式比较。实现这两个测试对addCalledTimes
的方式是实现细节。 一般的经验法则是, 验证可以区分我们是使用存根还是模拟(或混合混合)。
让我们先不进行理论讨论,然后再回到地面。 存根实际上如何在Swift中实现? 我们想要创建一个符合协议的对象,并有机会1)验证是否已使用期望的参数调用了给定的函数,以及2)控制函数返回了值。
有许多方法可以做到这一点,最简单的方法就是引入想要跟踪到存根中的行为一样多的属性。 例如,如果要在给定的时间调用该函数,请添加一个计数器,在函数中对其进行递增,然后在测试后验证其值-如上面的代码段所示。
但是,一种替代方法是添加一个附加变量,该变量提供一个函数占位符 (具有与该函数几乎相同的签名类型),并在遵守协议时调用它。 我写这本书几乎是因为如果测试用例完全希望忽略给定的功能,我们将其设为可选只是为了方便。
在此博客文章中,我们将讨论协议功能,因为变量相对于存根而言相对简单-仅通过引入实例即可。
为了这篇文章的缘故,让我们考虑一下最简单的协议,该协议将用户名同步保存到数据库中,并返回一个布尔值来通知过程是否成功。 它的模拟看起来像这样:
对于函数addUser
,我们引入了一个变量addUserAction
-每次调用addUser
时addUser
对其求值的函数。 然后,在测试场景中,您可以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:)
。