跳到主要内容

测试:按风险买保险

这一章讲三件事: 测试到底买的是什么;力气该往哪儿投(风险矩阵); 以及测试本身为什么会「抖」,怎么把抖动消掉。 读完你会有一套「该不该写这个测试」的判断法,和一份「测试不稳定」的检修单。

1. 这一章讲什么

第 02 章说过,改旧代码前要先「立栅栏」——那道栅栏就是测试。但原书开篇先泼了盆冷水:编写、运行、修复测试会让人感觉很忙碌,糟糕的测试本身才是繁忙的工作——它增加所有开发者的开销,却不提供价值,还会让整个测试套件变得不稳定1

本章在全书链条里的位置:它和第 06 章(代码评审)是代码合并前的两道闸;它也是第 08 章部署时的安全网、第 09 章设计文档里「测试计划」一节的弹药库。

2. 顶层全景

「要不要写这个测试?」

├─ 测试买什么? 五种用途:验证 / 保护旧行为 / 暴露接口设计 / 当文档 / 当游乐场
├─ 哪一层? 单元(快) → 集成(慢) → 系统(端到端) → 性能 → 验收
├─ 用什么工具? 模拟库 / 测试框架 / 质量工具 —— 每个都有成本
├─ 投多少力气? 风险矩阵:风险 = 失败的可能性 × 影响;测试把风险往左下推
└─ 测试抖了? 九个不确定来源,九个修法(种子/注入时钟/0 端口/唯一路径/清状态…)

图说:自上而下是「定位—选型—分配—检修」;主走查在 3.5(注入时钟)。

3. 核心原理

3.1 一份保单,五种赔法

多数人以为测试只干一件事:验证代码能跑。原书数了五种用途,后四种常被忽略2:

  1. 验证:证明代码按规定生效了;
  2. 保护:挡住将来的无意修改——旧测试失败时,你必须判断「这是有意的行为变更,还是新 bug?」;
  3. 暴露接口设计:开发者通常最先在测试代码里和自己的业务代码打交道,笨拙的接口在测试里最先露馅;
  4. 当文档:测试说明了代码如何被交互,是资深程序员接手新代码库的首选入口;
  5. 当游乐场:用调试器逐行跑测试、为每个新发现的行为加一个测试——测试套件是最好的实验场。

其中第 3 条的副作用强烈到催生了一个流派:测试驱动开发(TDD,先写测试、看着它失败、再写代码让它通过的实践)——它强迫你在写出一堆代码之前先想清楚行为和接口3

3.2 五种测试类型:一个光谱,不是一张清单

原书强调测试类型有几十种,它只讲常见的五种,目标是「奠定一个坚实的基础」4。关键不是背定义,而是看清快与稳的兑换率——越往上,测试越接近真实,也越慢、越难定位:

类型验证什么代价
单元测试单个方法或行为快、短、集中,跑在开发者的笔记本上;失败时一眼看出哪坏了
集成测试多个组件拼起来还能不能工作设置复杂、跑得慢、反馈周期长;能抓到单元测试各自都绿、拼起来才炸的问题
系统测试整个系统端到端(从入口一路走到出口)地模拟真实用户最贵;系统太大无法整体发布时,用「合成监控」在生产环境里模拟注册、浏览、下单来补位
性能测试负载测试看不同压力下的表现;压力测试把系统推到崩溃服务于容量规划和服务目标定义
验收测试由客户(或代理人)验证交付是否达标常见于企业软件,写进合同;非正式的变体就是「我改了点东西,你看看一切还好吧?」

原书插了一个洗碗机的故事给「集成」下注脚:德米特里选好了功能完美的洗碗机,结账前销售问了一句「你家是不是有个抽屉正好横在这台洗碗机门前?」——洗碗机门上外凸的把手会完全挡死抽屉。功能完美的洗碗机和功能完美的橱柜互不兼容;销售显然见过太多次这种「各自都对、拼起来就坏」5

还有一个务实的提醒:原书作者调研了不少成功开源项目,发现许多项目缺某些类型的测试、有的连「单元测试」和「集成测试」都混为一谈——所以别执意把分类做得完美无缺,成功的项目都在现实世界里做务实的测试决定6

3.3 工具箱与它的价签

测试工具分三类:帮你写测试的(模拟库)、帮你跑测试的(测试框架)、帮你检查代码的(质量工具)。原书的总原则放在最前面:每个新工具都有成本——人人都得学它,它拖着自己的依赖,有的还拖慢测试;利弊没证明之前别引入7

模拟库(mock,用假对象顶替真实依赖,返回预先写死的应答——所谓「硬编码」,指答案直接填死在代码里)是单元测试的主力:它把网络调用、外部系统挡在门外,让测试又快又稳8。两个警告:一是别往业务代码里塞测试专用参数(「不要在你的方法中添加布尔型的参数 isTest!」——这是原书原话级的反模式)9;二是对模拟库的过度依赖本身就是一种代码异味:它说明你的代码耦合得太紧,正确动作是重构代码把计算逻辑和 I/O 分开,而不是造更精巧的假对象10

测试框架管生命周期:setup(每个测试前准备数据)和 teardown(之后清理)有多个作用域可选,但别指望 teardown 在所有情况下都会运行——某个测试灾难性地崩掉整个进程时,它就不跑了11。框架还管执行调度:串行更安全,并行(同时跑多个测试)更快但容易被共享状态污染12

质量工具里最常被滥用的是覆盖率(测试套件执行过的代码行占比)。原书给了经验值:以合理的覆盖率为目标,经验值是 65% 到 85%13。但同一节里反复敲打:覆盖率是指南,不是硬性规则——在测试覆盖率为 100% 的代码库里,完全可能存在严重的 bug;执行过不等于验证过14

3.4 力气怎么分:风险矩阵

这是本章的决策核心,原书给了明确的口径:风险矩阵将风险定义为失败的可能性和影响;两者的交叉点定义了一块代码的风险15

失败的可能性
高 │ 中风险区 高风险区
│ (常坏但不疼) (常坏又疼 →先写测试)
│─────────────────────────────────
低 │ 低风险区 中风险区
│ (可以不测) (罕见但疼 →安排测试)
└─────────────────────────────────
小 大
失败的影响
图说:纵轴=失败的可能性,横轴=失败的影响(原书图 6-1 的轴向)。
测试把风险往左下方推——写得越多,失败的可能性越低。
先攻右上角;左下角的代码(以及那些注定要被废弃的
代码)不值得测试。

配套的三个「不值得」:为提高覆盖率而写的测试不值得——测数据库包装器、第三方库、基本变量赋值,「即使它们能提高覆盖率指标,也是毫无价值的」16;给自动生成的代码手写测试不值得——生成器本身被彻底测过,真要管就改覆盖率工具的配置17;当代码没坏也让测试红着不值得——当代码没有被破坏时,我们应该不需要去修复测试;一次正常的内部重构就弄碎一堆测试,只会让程序员对红灯见怪不怪18

还有一个组织层面的边界:测试是你自己的责任。许多公司有正式的 QA 团队,但他们的职责是性能测试、集成测试、测试基础设施——「QA 团队早就不写单元测试了,那些日子早就过去了」19

3.5 主走查:一只忽过忽挂的测试,和它的手术

现在把镜头对准本章最值钱的机制:确定性(同样的输入永远得到同样的输出,反之就是非确定性)。非确定性测试又叫拍打测试——间歇性失败,十次里失败一两次,你查不出是测试的问题还是代码的问题,最后只能无视它,而它迟早会掩护一个真正的 bug 提交进去20

原书给了一个完整的 Ruby 例子:SimpleThrottler,一个限流器——同一秒内操作数超过上限(默认每秒 1000 次)就触发节流21。第一版长这样:

class SimpleThrottler
def initialize(max_per_sec = 1000)
@last_sec = Time.now.to_i # ← 病根在这:直接读系统时钟
@count_this_sec = 0
end

def maybe_throttle
if Time.now.to_i == @last_sec and @count_this_sec > @max_per_sec
throttle()
@count_this_sec = 0
end
@last_sec = Time.now.to_i
end
end

它为什么测不了? 想验证「第 1001 次操作会触发节流」,测试必须制造出「同一秒内塞进 1001 次操作」的局面。但时间不由你控制:原书给过同族的例子——一段等 500 毫秒的代码,499 毫秒内跑完测试就通过,501 毫秒就失败22。测试机的 CPU 抖一下、操作系统把你的进程饿一会儿,断言就翻脸。没有对时钟的控制,就不可能正确地测试节流逻辑——原书原话23

手术: 把时钟从「自己找」改成「外面递进来」24:

def initialize(max_per_sec = 1000, clock = Time) # 时钟变成参数
@clock = clock
@last_sec = clock.now.to_i
end

def maybe_throttle
if @clock.now.to_i == @last_sec and @count_this_sec > @max_per_sec
...
end

改动只有两处:构造时把时钟对象存成 @clock,之后一律问 @clock.now。生产代码什么都不用改——默认参数就是真实时钟。测试代码则递进一个假时钟:第一次 now 返回 1000 秒,喂 1000 次操作;然后让假时钟仍返回 1000(或者走一个整数序列),塞第 1001 次——节流必然触发,想触发几次触发几次。走查全链:

旧版: 测试 → 真时钟(你说了不算) → 断言看运气 → 拍打
新版: 测试 → 假时钟(now=1000,由测试编排) → 1001 次操作 → 必然节流 → 稳定通过

这个手法叫依赖注入:把对象依赖的东西(时钟、随机数、文件系统)变成可以从外面传入的参数25。它是本章九招里威力最大的一招,因为它反过来的收益更大——为了让测试好写而做的注入,顺便让代码变得更好改(换个时钟源就是换一个参数)。

3.6 检修单:九个不确定来源,九个修法

把拍打测试的常见病根抄成一张检修单26:

病根修法
随机数用系统时钟当种子(种子是随机数生成器的起点值)常数当种子——同一序列,必过或必挂,不再随机27
单元测试里调远程系统(会超时、会宕、还要网络可达)单元测试禁调远程;用模拟库顶替,远程留给集成测试28
直接读系统时钟(now/sleep)把「现在几点」改成从外面递进来——3.5 的主走查就是这场手术
用休眠等另一个线程完成(「休眠 30 分钟的测试,最快也要 30 分钟」)重组步骤让它确定;并发(多个执行流同时在跑)代码实在给不了确定性,要认29
套接字、文件句柄用完不关(泄漏到最后,操作系统拒绝再开)try-with-resource;共享资源用 setup/teardown 管理30
绑死某个固定端口(别的测试占着就挂)绑定 0 端口——让操作系统现分配一个空闲端口,测试再取回来用31
写死文件路径和数据库位置(测试互相踩)动态生成唯一路径(tempfile 工具类,或直接拼 UUID)32
状态不清(「不要让失败的测试留下残渣」——内存计数器、数据库记录都是状态)setup/teardown 重置;套件间隙重建环境(容器最彻底,但慢,适合大组)
依赖执行顺序(前一个测试写的数据,后一个默认存在)每个测试自给自足:setup 备数据,teardown 清场33

4. 作者的判断与证据

  • 「风险=可能性×影响」:作者采纳的通用工程框架(非本书原创),用它裁决「测什么、不测什么」;这是本章的口径基石。
  • 「覆盖率 65%-85%」:原书自述为「经验值」,没有给出来源——照实说,这是个经验数,不是研究结论。
  • 「QA 早就不写单元测试」:作者的行业观察;与 3.4 的「自己写测试」配套。
  • SimpleThrottler:原书自带的示范代码,本章主走查完全沿用它;它是教学构造,不是生产代码。
  • 洗碗机故事:轶事,用来钉「集成缺陷」的直觉;故事的另一半(解决方案是买内嵌把手的型号)只是幽默,没有工程含义。

5. 边界与局限

  • 类型边界刻意模糊:原书明说了开源项目普遍分类混乱,所以本章的类型表是教学坐标,别拿去当评审标准。
  • 没有讲基于属性的测试:原书在「升级加油站」里承认调查过但没写进来,指路去《程序员修炼之道》——随机生成大量输入、用性质(而非具体用例)断言的测试法,本书不覆盖。
  • 并发测试没有给出确定性方案:3.6 只承诺「做出真诚的努力」,原书承认并发场景「并不总能提供确定性」34
  • 覆盖率一节只讨论了行覆盖率;分支覆盖率、变异测试等更精细的度量不在书里。

6. 可带走的

  1. 测试买五样:验证、保护、暴露接口设计、文档、游乐场——只写「验证」型测试的人亏了四样;
  2. 单元测试→集成测试→系统测试是快换稳的兑换表;洗碗机问题只出现在兑换表的右半边;
  3. 每个测试工具都有价签;模拟库用过头是紧耦合的信号,先重构再考虑造假对象;
  4. 力气按风险矩阵投:先攻「可能坏 × 坏了疼」的右上角,别给左下角上保险;
  5. 覆盖率是指南不是 KPI:100% 的代码库照样能藏严重 bug,65%-85% 是经验区间;
  6. 代码没坏时不需要修测试——一重构就碎的测试是测了实现细节,不是测了行为;
  7. 测试抖了按检修单查:种子、远程调用、时钟、休眠、泄漏、端口、路径、状态、顺序;
  8. 最值钱的一招是依赖注入:把时钟和随机数变成参数——测试稳定了,代码也灵活了;
  9. 单元测试是你自己的责任,QA 团队不会替你写;测试套件是团队的名气,红灯见怪不怪之日就是 bug 入库之时。

7. 原文地图

主题原书章原文位置
糟糕测试是繁忙工作第6章 测试text/15-ch06.txt:4(搜「繁忙的工作」)
测试五种用途第6章 测试text/15-ch06.txt:16(搜「游乐场」) · text/15-ch06.txt:22(搜「保护现有的行为」)
TDD第6章 测试text/15-ch06.txt:34(搜「TDD」)
单元测试第6章 测试text/15-ch06.txt:50(搜「单元测试是验证代码」)
集成测试与洗碗机第6章 测试text/15-ch06.txt:57(搜「集成测试验证」) · text/15-ch06.txt:66(搜「洗碗机」)
合成监控第6章 测试text/15-ch06.txt:91(搜「合成监控」) · text/15-ch06.txt:92(搜「合成监控脚本」)
负载与压力第6章 测试text/15-ch06.txt:95(搜「负载测试和压力测试」) · text/15-ch06.txt:98(搜「推高到崩溃」)
验收测试第6章 测试text/15-ch06.txt:101(搜「验收测试是指」)
务实的测试决定第6章 测试text/15-ch06.txt:118(搜「完美无缺」)
工具的成本第6章 测试text/15-ch06.txt:135(搜「各自的成本」)
模拟库第6章 测试text/15-ch06.txt:144(搜「模拟库通常用于单元测试」) · text/15-ch06.txt:156(搜「isTest」)
过度模拟=异味第6章 测试text/15-ch06.txt:162(搜「代码异味,它表明代码紧紧地耦」)
teardown 不保证运行第6章 测试text/15-ch06.txt:184(搜「不要期望」)
串行与并行第6章 测试text/15-ch06.txt:189(搜「串行或并行」)
覆盖率经验值第6章 测试text/15-ch06.txt:230(搜「65%到 85%」)
100% 也有 bug第6章 测试text/15-ch06.txt:307(搜「100%的代码库」)
QA 不写单元测试第6章 测试text/15-ch06.txt:259(搜「那些日子早就过去了」)
风险矩阵(全书口径)第6章 测试text/15-ch06.txt:319(搜「风险矩阵将风险定义为失败的可能」) · text/15-ch06.txt:320(搜「性和影响」)
向左下方转移第6章 测试text/15-ch06.txt:326(搜「向左下方转移」)
毫无价值的测试第6章 测试text/15-ch06.txt:295(搜「毫无价值」)
自动生成代码第6章 测试text/15-ch06.txt:232(搜「自动生成」)
代码没坏不修测试第6章 测试text/15-ch06.txt:300(搜「见怪不怪」)
拍打测试第6章 测试text/15-ch06.txt:339(搜「拍打测试」)
主走查:SimpleThrottler第6章 测试text/15-ch06.txt:396(搜「代码清单 6-1」) · text/15-ch06.txt:426(搜「不能保证在测试中触发」) · text/15-ch06.txt:433(搜「代码清单 6-2」)
500 毫秒的脆弱第6章 测试text/15-ch06.txt:387(搜「等待 500 毫秒」) · text/15-ch06.txt:388(搜「499 毫秒」)
没有对时钟的控制第6章 测试text/15-ch06.txt:429(搜「没有对时钟的控制」)
依赖注入第6章 测试text/15-ch06.txt:462(搜「依赖注入」)
常数种子第6章 测试text/15-ch06.txt:359(搜「一个值作为种子」) · text/15-ch06.txt:364(搜「常数」)
单元测试禁远程第6章 测试text/15-ch06.txt:369(搜「网络跳转」)
休眠 30 分钟第6章 测试text/15-ch06.txt:475(搜「休眠 30 分钟」)
资源泄漏第6章 测试text/15-ch06.txt:485(搜「泄露操作系统的资源」)
0 端口第6章 测试text/15-ch06.txt:510(搜「绑定到 0 端口」)
唯一路径第6章 测试text/15-ch06.txt:523(搜「tempfile」) · text/15-ch06.txt:522(搜「UUID」)
留下残渣第6章 测试text/15-ch06.txt:541(搜「留下残渣」)
执行顺序第6章 测试text/15-ch06.txt:550(搜「执行顺序」)

Footnotes

  1. 出处:「第6章 测试」第 5 段(text/15-ch06.txt:5,搜「不提供价值」)。原文:糟糕的测试会增加开发人员的开销而不提供价值,并且还会增加测试套件的不稳定性。

  2. 出处:「第6章 测试」第 13 段(text/15-ch06.txt:13,搜「其他作用」)与第 16 段(text/15-ch06.txt:16,搜「游乐场」)。

  3. 出处:「第6章 测试」第 34 段(text/15-ch06.txt:34,搜「TDD」)与第 37 段(text/15-ch06.txt:37,搜「接口设计和集成」)。

  4. 出处:「第6章 测试」第 46 段(text/15-ch06.txt:46,搜「几十种」)。

  5. 出处:「第6章 测试」第 66 段(text/15-ch06.txt:66,搜「洗碗机」)与第 82 段(text/15-ch06.txt:82,搜「完全不兼容」)。

  6. 出处:「第6章 测试」第 118 段(text/15-ch06.txt:118,搜「完美无缺」)与第 119 段(text/15-ch06.txt:119,搜「务实的测试决定」)。

  7. 出处:「第6章 测试」第 135 段(text/15-ch06.txt:135,搜「各自的成本」)与第 138 段(text/15-ch06.txt:138,搜「利弊之前」)。

  8. 出处:「第6章 测试」第 144 段(text/15-ch06.txt:144,搜「模拟库通常用于单元测试」)与第 148 段(text/15-ch06.txt:148,搜「消除外部依赖性」)。

  9. 出处:「第6章 测试」第 156 段(text/15-ch06.txt:156,搜「isTest」)。

  10. 出处:「第6章 测试」第 162 段(text/15-ch06.txt:162,搜「代码异味,它表明代码紧紧地耦」)与第 164 段(text/15-ch06.txt:164,搜「I/O」)。

  11. 出处:「第6章 测试」第 184 段(text/15-ch06.txt:184,搜「不要期望」)与第 185 段(text/15-ch06.txt:185,搜「所有情况下都能运行」)。

  12. 出处:「第6章 测试」第 189 段(text/15-ch06.txt:189,搜「串行或并行」)与第 191 段(text/15-ch06.txt:191,搜「共享的状态」)。

  13. 出处:「第6章 测试」第 230 段(text/15-ch06.txt:230,搜「65%到 85%」)。

  14. 出处:「第6章 测试」第 304 段(text/15-ch06.txt:304,搜「硬性的规则」)与第 307 段(text/15-ch06.txt:307,搜「100%的代码库」)。

  15. 出处:「第6章 测试」第 319 段(text/15-ch06.txt:319,搜「风险矩阵将风险定义为失败的可能」)与第 320 段(text/15-ch06.txt:320,搜「性和影响」)。原文:风险矩阵将风险定义为失败的可能性和影响;图 6-1 的纵向为失败的可能性,横向为失败的影响。

  16. 出处:「第6章 测试」第 294 段(text/15-ch06.txt:294,搜「数据库包装器」)与第 295 段(text/15-ch06.txt:295,搜「毫无价值」)。

  17. 出处:「第6章 测试」第 232 段(text/15-ch06.txt:232,搜「自动生成」)与第 312 段(text/15-ch06.txt:312,搜「彻底测试的」)。

  18. 出处:「第6章 测试」第 300 段(text/15-ch06.txt:300,搜「变得见怪不怪」)与第 300 段(text/15-ch06.txt:300,搜「见怪不怪」)。

  19. 出处:「第6章 测试」第 258 段(text/15-ch06.txt:258,搜「QA 团队早就不写单」)与第 259 段(text/15-ch06.txt:259,搜「那些日子早就过去了」)。

  20. 出处:「第6章 测试」第 339 段(text/15-ch06.txt:339,搜「拍打测试」)与第 342 段(text/15-ch06.txt:342,搜「忽略它们」)。

  21. 出处:「第6章 测试」第 392 段(text/15-ch06.txt:392,搜「SimpleThrottler」)与第 394 段(text/15-ch06.txt:394,搜「节流方法」)。

  22. 出处:「第6章 测试」第 387 段(text/15-ch06.txt:387,搜「等待 500 毫秒」)与第 388 段(text/15-ch06.txt:388,搜「499 毫秒」)。

  23. 出处:「第6章 测试」第 429 段(text/15-ch06.txt:429,搜「没有对时钟的控制」)。原文:没有对时钟的控制,就不可能正确地测试节流的逻辑。

  24. 出处:「第6章 测试」第 433 段(text/15-ch06.txt:433,搜「代码清单 6-2」)与第 436 段(text/15-ch06.txt:436,搜「clock=Time」)。

  25. 出处:「第6章 测试」第 462 段(text/15-ch06.txt:462,搜「依赖注入」)与第 463 段(text/15-ch06.txt:463,搜「模拟值」)。

  26. 出处:「第6章 测试」第 349 段(text/15-ch06.txt:349,搜「休眠、超时和生成随机数」)。原文:非确定性通常是由对休眠、超时和生成随机数的不恰当处理引入的。

  27. 出处:「第6章 测试」第 359 段(text/15-ch06.txt:359,搜「一个值作为种子」)与第 364 段(text/15-ch06.txt:364,搜「常数」)。

  28. 出处:「第6章 测试」第 369 段(text/15-ch06.txt:369,搜「网络跳转」)与第 381 段(text/15-ch06.txt:381,搜「仅在集成测试中被需要」)。

  29. 出处:「第6章 测试」第 475 段(text/15-ch06.txt:475,搜「休眠 30 分钟」)与第 481 段(text/15-ch06.txt:481,搜「并不总能提供确定性」)。

  30. 出处:「第6章 测试」第 485 段(text/15-ch06.txt:485,搜「泄露操作系统的资源」)与第 498 段(text/15-ch06.txt:498,搜「资源管理技巧」)。

  31. 出处:「第6章 测试」第 510 段(text/15-ch06.txt:510,搜「绑定到 0 端口」)与第 511 段(text/15-ch06.txt:511,搜「自动去选择」)。

  32. 出处:「第6章 测试」第 519 段(text/15-ch06.txt:519,搜「动态地生成唯一的文件名」)与第 523 段(text/15-ch06.txt:523,搜「tempfile」)。

  33. 出处:「第6章 测试」第 550 段(text/15-ch06.txt:550,搜「执行顺序」)与第 559 段(text/15-ch06.txt:559,搜「setup 和 teardown 方法」)。

  34. 出处:「第6章 测试」第 481 段(text/15-ch06.txt:481,搜「并不总能提供确定性」)。