{T}

为什么你的测试不够好

核心命题

测试必须简单到一目了然,不需要证明其正确性。 谁来保证测试的正确性?答案是:没有人——因为给测试写测试会导致无限递归。唯一的出路是将测试写得足够简单,使其正确性不证自明。测试质量问题的根因,几乎都可以归结为"测试不够简单"。


1. 测试质量的典型问题与根因

1.1 常见测试反模式

反模式表现根因后果
Flaky Test时过时不过非确定性(时间/随机/并发/外部依赖)破坏信任,团队忽视测试
Setup 过重测试环境搭建耗时测试耦合了过多外部依赖测试慢,不愿运行
测试间依赖必须按特定顺序执行共享可变状态不可并行,不可重复
测试测实现修改实现导致测试失败测试断言了内部实现细节重构困难,测试脆弱
God Test一个测试验证所有行为未按单一关注点拆分失败时难以定位问题

1.2 Flaky Test 的治理

Flaky Test 是 CI/CD 流水线的头号敌人。2024 年 Google 工程生产力团队的研究显示,约 16% 的 Google 内部测试存在 Flaky 行为。

图表渲染中…

Flaky Test 的处理策略

  1. 隔离:标记为 Flaky,从 CI 关键路径中移除
  2. 诊断:多次运行(--repeat=100)定位非确定性来源
  3. 修复:消除非确定性根因
  4. 移除:无法修复的 Flaky Test 应删除,而非保留

2. 简单测试的结构化规范

2.1 一个好测试的结构

plaintext
测试 = 准备(Arrange)+ 执行(Act)+ 断言(Assert)

好测试的特征

特征含义检验方法
单一断言原则一个测试只验证一个行为测试名与断言一一对应
无隐式依赖所有依赖显式构造无全局状态访问
确定性相同输入→相同输出多次运行结果一致
自解释测试名即文档阅读测试名即可理解行为
快速毫秒级完成无 I/O、无网络、无 sleep

2.2 测试的 4 阶段生命周期

图表渲染中…

Setup/Teardown 的最佳实践

  • 使用 Builder Pattern 构建测试数据,提高可读性
  • 使用 JUnit Rule/Extension 管理共享资源,避免手动 Teardown
  • Test Fixture 应最小化——只构造当前测试所需的数据

2.3 测试代码与生产代码同等重要

维度生产代码测试代码
代码质量需要重构同样需要重构
命名清晰表达意图同样清晰表达行为
DRY避免重复适度重复(测试应自包含)
设计模式使用适当模式Builder、Object Mother、Test Data

关键区别:测试代码中适度的重复是可接受的——每个测试应独立可读,不应为消除重复而引入共享的复杂 Setup 逻辑。


3. 测试替身的精确使用

3.1 Mock vs Stub 的选择原则

场景选择原因
验证交互行为(是否调用了某方法)Mock关注"做了什么"
隔离返回值(需要特定数据)Stub关注"返回什么"
需要简化实现(如内存数据库)Fake关注"行为等价"

3.2 Mock 的过度使用问题

London School 的批评:过度使用 Mock 会导致测试与实现细节强耦合——重构实现时测试大量失败,即使行为未变。

缓解策略

  • Mock 接口而非具体类
  • Mock 外部边界(Repository、Gateway),不 Mock 内部协作
  • 优先使用 Fake(如内存数据库),仅在必要时使用 Mock

4. 测试覆盖率:度量而非目标

4.1 覆盖率类型的精确理解

覆盖率类型含义局限
行覆盖率被执行的代码行比例不检查分支
分支覆盖率被执行的分支比例不检查条件组合
路径覆盖率被执行的执行路径比例组合爆炸,实际不可达
Mutation 覆盖率被杀死的变异比例最严格,但计算成本高

4.2 覆盖率的正确使用方式

图表渲染中…

原则:覆盖率是发现工具(发现未测试的代码),而非目标(追求数字)。100% 行覆盖率不等于 100% 行为覆盖。


5. 总结

核心要义:测试必须简单到一目了然。如果测试比被测代码还复杂,那测试本身就成了问题。Flaky Test、Setup 过重、测试间依赖等问题的根因,都是测试不够简单。将复杂测试拆分为简单测试,是测试质量提升的第一步。


延伸阅读


原文存档

以下为郑晔原文完整内容,保留作为参考。

16 | 为什么你的测试不够好?

你好!我是郑晔。今天是除夕,我在这里给大家拜年了,祝大家在新的一年里,开发越做越顺利!

关于测试,我们前面讲了很多,比如:开发者应该写测试;要写可测的代码;要想做好 TDD,先要做好任务分解,我还带你进行了实战操作,完整地分解了一个任务。

但有一个关于测试的重要话题,我们始终还没聊,那就是测试应该写成什么样。今天我就来说说怎么把测试写好。

你或许会说,这很简单啊,前面不都讲过了吗?不就是用测试框架写代码吗?其实,理论上来说,还真应该就是这么简单,但现实情况却往往相反。我看到过很多团队在测试上出现过各种各样的问题,比如:

  • 测试不稳定,这次能过,下次过不了;
  • 有时候是一个测试要测的东西很简单,测试周边的依赖很多,搭建环境就需要很长的时间;
  • 这个测试要运行,必须等到另外一个测试运行结束;
  • ……

如果你也在工作中遇到过类似的问题,那你理解的写测试和我理解的写测试可能不是一回事,那问题出在哪呢?

为什么你的测试不够好呢?

主要是因为这些测试不够简单。只有将复杂的测试拆分成简单的测试,测试才有可能做好。

简单的测试

测试为什么要简单呢?有一个很有趣的逻辑,不知道你想没想过,测试的作用是什么?显然,它是用来保证代码的正确性。随之而来的一个问题是,谁来保证测试的正确性?

许多人第一次面对这个问题,可能会一下子懵住,但脑子里很快便会出现一个答案:测试。但是,你看有人给测试写测试吗?肯定没有。因为一旦这么做,这个问题会随即上升,谁来保证那个测试的正确性呢?你总不能无限递归地给测试写测试吧。

既然无法用写程序的方式保证测试的正确性,我们只有一个办法: 把测试写简单,简单到一目了然,不需要证明它的正确性。 所以,如果你见到哪个测试写得很复杂,它一定不是一个好的测试。

既然说测试应该简单,我们就来看看一个简单的测试应该是什么样子。下面我给出一个简单的例子,你可以看一下。

plaintext
@Test
void should_extract_HTTP_method_from_HTTP_request() {
  // 前置准备
  request = mock(HttpRequest.class);
  when(request.getMethod()).thenReturn(HttpMethod.GET);
  HttpMethodExtractor extractor = new HttpMethodExtractor();
 
  // 执行
  HttpMethod method = extractor.extract(request);
 
  // 断言
  assertThat(method, is(HttpMethod.GET);
 
  // 清理
}
 

这个测试来自我的开源项目 Moco,我稍做了一点调整,便于理解。这个测试很简单,从一个 HTTP 请求中提取出 HTTP 方法。

我把这段代码分成了四段,分别是 前置准备、执行、断言和清理,这也是一般测试要具备的四段。

  • 这几段的核心是中间的执行部分,它就是测试的目标,但实际上,它往往也是最短小的,一般就是一行代码调用。其他的部分都是围绕它展开的,在这里就是调用 HTTP 方法提取器提取 HTTP 方法。
  • 前置准备,就是准备执行部分所需的依赖。比如,一个类所依赖的组件,或是调用方法所需要的参数。在这个测试里面,我们准备了一个 HTTP 请求,设置了它的方法是一个 GET 方法,这里面还用到了之前提到的 Mock 框架,因为完整地设置一个 HTTP 请求很麻烦,而且与这个测试也没什么关系。
  • 断言是我们的预期,就是这段代码执行出来怎么算是对的。这里我们判断了提取出来的方法是否是 GET 方法。另外补充一点,断言并不仅仅是 assert,如果你用 Mock 框架的话,用以校验 mock 对象行为的 verify 也是一种断言。
  • 清理是一个可能会有的部分,如果你的测试用到任何资源,都可以在这里释放掉。不过,如果你利用好现有的测试基础设施(比如,JUnit 的 Rule),遵循好测试规范的话,很多情况下,这个部分就会省掉了。

怎么样,看着很简单吧,是不是符合我前面所说的不证自明呢?

测试的坏味道

有了对测试结构的了解,我们再来说说常见的测试“坏味道”。

首先是执行部分。不知道你有没有注意到,前面我提到执行部分时用了一个说法,一行代码调用。是的,第一个“坏味道”就来自这里。

很多人总想在一个测试里做很多的事情,比如,出现了几个不同方法的调用。请问,你的代码到底是在测试谁呢?

这个测试一旦出错,就需要把所有相关的几个方法都查看一遍,这无疑是增加了工作的复杂度。

也许你会问,那我有好几个方法要测试,该怎么办呢?很简单,多写几个测试就好了。

另一个典型“坏味道”的高发区是在断言上,请记住, 测试一定要有断言。 没有断言的测试,是没有意义的,就像你说自己是世界冠军,总得比个赛吧!

我见过不少人写了不少测试,但测试运行几乎从来就不会错。出于好奇,我打开代码一看,没有断言。

没有断言当然就不会错了,写测试的同事还很委屈地说,测试不好写,而且,他已经验证了这段代码是对的。就像我前面讲过的,测试不好写,往往是设计的问题,应该调整的是设计,而不是在测试这里做妥协。

还有一种常见的“坏味道”:复杂。最典型的场景是,当你看到测试代码里出现各种判断和循环语句,基本上这个测试就有问题了。

举个例子,测试一个函数,你的断言写在一堆 if 语句中,美其名曰,根据条件执行。还是前面提到的那个观点,你怎么保证这个测试函数写的是对的?除非你用调试的手段,否则,你都无法判断你的条件分支是否执行到了。

你或许会疑问,我有一大堆不同的数据要测,不用循环不用判断,我怎么办呢? 你真正应该做的是,多写几个测试,每个测试覆盖一种场景。

一段旅程(A-TRIP)

怎么样的测试算是好的测试呢?有人做了一个总结 A-TRIP,这是五个单词的缩写,分别是

  • Automatic,自动化;
  • Thorough,全面的;
  • Repeatable,可重复的;
  • Independent,独立的;
  • Professional,专业的。

下面,我们看看这几个单词分别代表什么意思。

Automatic,自动化。 有了前面关于自动化测试的铺垫,这可能最好理解,就是把测试尽可能交给机器执行,人工参与的部分越少越好。

这也是我们在前面说,测试一定要有断言的原因,因为一个测试只有在有断言的情况下,机器才能自动地判断测试是否成功。

Thorough,全面,应该尽可能用测试覆盖各种场景。 理解这一点有两个角度。一个是在写代码之前,要考虑各种场景:正常的、异常的、各种边界条件;另一个角度是,写完代码之后,我们要看测试是否覆盖了所有的代码和所有的分支,这就是各种测试覆盖率工具发挥作用的场景了。

当然,你想做到全面,并非易事,如果你的团队在补测试,一种办法是让测试覆盖率逐步提升。

Repeatable,可重复的。 这里面有两个角度:某一个测试反复运行,结果应该是一样的,这说的是,每一个测试本身都不应该依赖于任何不在控制之下的环境。如果有,怎么办,想办法。

比如,如果有外部的依赖,就可以采用模拟服务的手段,我的 Moco 就是为了解决外部依赖而生的,它可以模拟外部的 HTTP 服务,让测试变得可控。

有的测试会依赖数据库,那就在执行完测试之后,将数据库环境恢复,像 Spring 的测试框架就提供了测试数据库回滚的能力。如果你的测试反复运行,不能产生相同的结果,要么是代码有问题,要么是测试有问题。

理解可重复性,还有一个角度,一堆测试反复运行,结果应该是一样的。这说明测试和测试之间没有任何依赖,这也是我们接下来要说的测试的另外一个特点。

Independent,独立的。 测试和测试之间不应该有任何依赖,什么叫有依赖?比如,如果测试依赖于外部数据库或是第三方服务,测试 A 在运行时在数据库里写了一些值,测试 B 要用到数据库里的这些值,测试 B 必须在测试 A 之后运行,这就叫有依赖。

我们不能假设测试是按照编写顺序运行的。比如,有时为了加快测试运行速度,我们会将测试并行起来,在这种情况下,顺序是完全无法保证的。如果测试之间有依赖,就有可能出现各种问题。

减少外部依赖可以用 mock,实在要依赖,每个测试自己负责前置准备和后续清理。如果多个测试都有同样的准备和清理呢?那不就是 setup 和 teardown 发挥作用的地方吗?测试基础设施早就为我们做好了准备。

Professional,专业的。 这一点是很多人观念中缺失的,测试代码,也是代码,也要按照代码的标准去维护。这就意味着你的测试代码也要写得清晰,比如:良好的命名,把函数写小,要重构,甚至要抽象出测试的基础库,在 Web 测试中常见的 PageObject 模式,就是这种理念的延伸。

看了这点,你或许会想,你说的东西有点道理,但我的代码那么复杂,测试路径非常多,我怎么能够让自己的测试做到满足这些要求呢?

我必须强调一个之前讲测试驱动开发强调过的观点: 编写可测试的代码。 很多人写不好测试,或者觉得测试难写,关键就在于,你始终是站在写代码的视角,而不是写测试的视角。如果你都不重视测试,不给测试留好空间,测试怎么能做好呢?

总结时刻

测试是一个说起来很简单,但很不容易写好的东西。在实际工作中,很多人都会遇到关于测试的各种各样问题。之所以出现问题,主要是因为这些测试写得太复杂了。测试一旦复杂了,我们就很难保证测试的正确性,何谈用测试保证代码的正确性。

我给你讲了测试的基本结构:前置准备、执行、断言和清理,还介绍了一些常见的测试“坏味道”:做了太多事的测试,没有断言的测试,还有一种看一眼就知道有问题的“坏味道”,测试里有判断语句。

怎么衡量测试是否做好了呢?有一个标准:A-TRIP,这是五个单词的缩写,分别是Automatic(自动化)、Thorough(全面)、Repeatable(可重复的)、Independent(独立的)和 Professional(专业的)。

如果今天的内容你只能记住一件事,那请记住: 要想写好测试,就要写简单的测试。

最后,我想请你分享一下,经过最近持续对测试的讲解,你对测试有了哪些与之前不同的理解呢?

感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给你的朋友。