单元测试概念梳理

本文不讨论任何 TDD 相关内容,仅仅涉及单元测试本身。本文提到的一些问题的确可以通过 TDD 来解决或减缓,但单元测试是可以独立存在的,如果你没有采用 TDD 的方式写测试。我表示遗憾,但是还是要考虑没有 TDD 的单元测试的。

什么是(好的)单元测试

要写测试大家都知道,但是怎样才能写好的单元测试呢?

对于程序员,不仅仅是要写测试,而是让写单元测试能给你最大的回报!!!(有效的测试)

单元测试的目的是让软件项目的成长更稳定(没测试会越来越累)

开头难的原因是要初始化测试真的挺烦的。

测试也不是越多越好,也要看价值

两种派别的单元测试(关键是了解不同的角度)

说了半天,到底什么才算是一个单元测试呢?

单元测试的定义:

可是人类语言实在太不精确了,导致了两种派别,它们最大的不同就是对于 isolated manner 的看法。 这两种派别是:经典派(classical)和伦敦派(London schools) 关键是第三点,什么才是 isolated manner 呢?

伦敦派认为: 被测系统和他的合作者,除了 SUT (system under test) 外,其他协作者都应该被 test double 替换掉 (test double 之后会说). 这样你就能专注于 sut, 而不用关心外部的影响。

经典派的理解是,要被隔离的不是代码,而是单元测试自己,多个单元测试应该是互相隔离的。

伦敦派的优点:

  1. 一旦测试失败,可以确定一定是 SUT 有问题,而不是其他相关代码
  2. 隔离了各种依赖关系
  3. 有着统一的规范,一个类一个测试,简单易懂

但是伦敦派有一个很大的问题,就是他们这样 mock 一切的做法,会让测试趋向于和具体实现耦合,这是我最不希望看到的。

经典派的经典书籍是 Kent Beck’s Test-Driven Development: By Example 伦敦派的经典书籍是 Growing Object-Oriented Software, Guided by Tests by Steve Freeman and Nat Pryce

个人更偏向于经典派。

单元测试的结构和常见套路

单元测试的结构: (AAA)

和写作文一样,起因,经过,结果。如果有多个,说明可以分解,一次只做一件事。

act 应该只有一行,不然说明 API 设计有问题

arrage 中的准备数据 fixture 可以使用工厂方法来创建

测试方法命名时可以使用非技术人员也能听懂的名称

参数化的命名可以减少类似测试的代码量,但是也可能减少阅读性

assert 相关库也能增强测试的可读性

4大核心

一个好的单元测试可以用下面4点来衡量:

假阳性(false positive),测试失败了,但其实生产代码没问题。是一个医学术语的隐喻。 它会降低你的警觉,当测试失败时,你不会把它当回事,这样一来测试代码的意义就消失了。 更严重的是,它会让你不再积极去写测试代码(破罐子破摔了)。 出现假阳性的原因是测试代码和 SUT 的具体实现绑的太紧,这样一来一旦生产代码被重构那测试就会报错,而实际上代码的行为没有改变。 这个问题在项目初期也许不明显,但随着项目的扩大,会越来越明显,所以一定要注意,防范于未燃。

慎用 mock

mock 会导致脆弱的测试(大多数情况) 先说一下 mock 和 stubs 的关系吧

         +--------------------+
         |                    |
         |    Test double     |
         |                    |
         |                    |
         +------------------+-+
               +^           ^
               |            |
               |            |
+--------------+-+          +--------------------+
|   Mock         |          |  Stub              |
|   (mock,spy)   |          |  (stub,dummy,fake) |
|                |          |                    |
+----------------+          +--------------------+

两者的区别是:mock 用来模拟 SUT 的输出交互, 而 Stubs 是模拟了 SUT 的输入交互, 这里举两个最常见的例子就知道了:

发送邮件 -> mock

从数据库中获取数据 -> stub

有些测试库可能会混用 mock 和 stubs 这两个名字,但是请用它们的作用来区分 (毕竟只有 mock 工具这样的说法,没挺过 stub 工具的说法,但是我们心理要清楚)

如果对和 stubs 的 assert 会导致脆弱的测试。因为它 assert 不是最终结果

CQS 中,替代 command 的是 mock, 替代 query 的是 stubs

测试测的应该是外部可观测的行为。除了下面提到的两种情况,其余都是实现细节。

  1. 暴露了一个操作,可以帮助客户端实现它的一个目标。一个操作可以是一个用于计算的方法或者产生一个副作用。
  2. 暴露了一个状态,可以帮助客户端实现它的一个目标。这个状态是当前系统的条件。

好的代码设计是让可观察的行为与公开方法保持一致,而让实现细节成为私有方法。 代码的细节泄漏其实就是让公开的方法做了可观察行为以外的事。

封装能让你的代码保持外部结构的不变,而把实现细节暴露出去就会让客户端不得不使用更多的分支来保持外部行为的一致。

有两种类型的交互,intra-system and inter-system. intra-system 应用中的类的交互 inter-system 应用与外部的交互

可见,前者是实现细节,后者是可观察行为。 使用 mock 去 assert intra-system 交互会导致脆弱的测试,mock 应该只被用在 inter-system 中。

三种测试风格

output-based testing, 对 SUT 输入并检测输出,前提是没有 hidden input, SUT 的唯一工作就是它的返回值

state-based testing 就是验证 SUT 在之后一个操作后的状态

communication-based testing 中,需要使用 mock 来验证 SUT 和协作者之间的交互。

经典流派更喜欢 state-based 风格,而伦敦派更喜欢 state-based 的风格(他们把协作者全都 mock 掉了,也只能测 SUT 自己的 state 了~)

两方都会使用 output-based 风格。

output-based 风格的测试质量最高,因为他只测输出,重构实现细节不会影响它。

State-based 风格的测试要麻烦点,比如你要确保没有因为为了要验证测试而暴露出原本是 private 的属性 (可以尝试使用帮助方法或者值对象来减轻这种情况)。

Communication-based 风格更加麻烦,需要使用 mock, 可读性也差。

这样看来 output-based 风格最好,说倒这种风格,就不难想到函数式编程,这里不展开说了。

重构测试

最大化测试价值和最小的维护代价

所有的生产代码都能被分为4类:

关于怎么分割代码,要看这三个重要指标:

另外可以参考 简洁架构, 六边形架构 或 DDD 等相关资料

注意,三者只能取二,你可以:

集成测试的好处和代价

什么是集成测试呢?很简单,不是单元测试的测试就是集成测试了。集成测试用来检验你的系统与 out of process dependencies 的工作。

一般来说,集成测试处理 controller, 单元测试处理算法与领域模型。

集成测试对重构更友好,单元测试的速度和可维护性更好。

丛测试金字塔中可以看到,集成测试的数量相对较少,运行也更慢,因此集成测试要慎用。

对于边缘情况,要多用单元测试覆盖,集成测试用于 happy path 或者单元测试覆盖不到的边缘情况。

集成测试需要外部依赖,外部依赖分为 out of process 和 … 可以通过这张表看清它们的关系

在集成测试中,对于 managed dependencies, 使用真实对象,而 unmanaged dependencies 只能使用 mock 工具了。

mock 的使用

mock 一般只用在和 unmanaged dependencies 的交互中,所以应该 mock 最边缘的类或方法。

spy 就是手写的 mock 而已,不过 spy 有个好处就是可以被复用,增加可读性。

controller 是唯一使用 unmanaged dependencies 的地方,而 mock 一般只用在和 unmanaged dependencies 的交互中,所以 mock 应该只出现在集成测试中。

只 mock 你自己拥有的类型,如果有第三方的库,先写一个适配器,再 mock 这些适配器,不要直接 mock 第三方的接口。

数据库相关

数据库的 schema 要放入版本控制。

reference data 和 regular data, 区别方法是,应用能否修改那些数据,能修改的就是 regular 数据,不能的就是 reference 数据。

每个开发者都要有一个独立的数据库实例

对于集成测试,顺序执行就够了,并行运行测试是单元测试的特性,Rails 也是最近才刚刚加入并行测试,并且它的原理就是创建多个测试数据库实例。

开始测试之前要记得先清空测试数据库中之前的数据。

避免使用内存数据库来测试,测试环境和生产环境越接近越好,没必要上一个不同的数据库,不同数据库还是有不少特性差异的。

anti-pattern