测试:按风险买保险
这一章讲三件事: 测试到底买的是什么;力气该往哪儿投(风险矩阵); 以及测试本身为什么会「抖」,怎么把抖动消掉。 读完你会有一套「该不该写这个测试」的判断法,和一份「测试不稳定」的检修单。
1. 这一章讲什么
第 02 章说过,改旧代码前要先「立栅栏」——那道栅栏就是测试。但原书开篇先泼了盆冷水:编写、运行、修复测试会让人感觉很忙碌,糟糕的测试本身才是繁忙的工作——它增加所有开发者的开销,却不提供价值,还会让整个测试套件变得不稳定1。
本章在全书链条里的位置:它和第 06 章(代码评审)是代码合并前的两道闸;它也是第 08 章部署时的安全网、第 09 章设计文档里「测试计划」一节的弹药库。
2. 顶层全景
「要不 要写这个测试?」
│
├─ 测试买什么? 五种用途:验证 / 保护旧行为 / 暴露接口设计 / 当文档 / 当游乐场
├─ 哪一层? 单元(快) → 集成(慢) → 系统(端到端) → 性能 → 验收
├─ 用什么工具? 模拟库 / 测试框架 / 质量工具 —— 每个都有成本
├─ 投多少力气? 风险矩阵:风险 = 失败的可能性 × 影响;测试把风险往左下推
└─ 测试抖了? 九个不确定来源,九个修法(种子/注入时钟/0 端口/唯一路径/清状态…)
图说:自上而下是「定位—选型—分配—检修」;主走查在 3.5(注入时钟)。
3. 核心原理
3.1 一份保单,五种赔法
多数人以为测试只干一 件事:验证代码能跑。原书数了五种用途,后四种常被忽略2:
- 验证:证明代码按规定生效了;
- 保护:挡住将来的无意修改——旧测试失败时,你必须判断「这是有意的行为变更,还是新 bug?」;
- 暴露接口设计:开发者通常最先在测试代码里和自己的业务代码打交道,笨拙的接口在测试里最先露馅;
- 当文档:测试说明了代码如何被交互,是资深程序员接手新代码库的首选入口;
- 当游乐场:用调试器逐行跑测试、为每个新发现的行为加一个测试——测试套件是最好的实验场。
其中第 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. 可带走的
- 测试买五样:验证、保护、暴露接口设计、文档、游乐场——只写「验证」型测试的人亏了四样;
- 单元测试→集成测试→系统测试是快换稳的兑换表;洗碗机问题只出现在兑换表的右半边;
- 每个测试工具都有价签;模拟库用过头是紧耦合的信号,先重构再考虑造假对象;
- 力气按风险矩阵投:先攻「可能坏 × 坏了疼」的右上角,别给左下角上保险;
- 覆盖率是指南不是 KPI:100% 的代码库照样能藏严重 bug,65%-85% 是经验区间;
- 代码没坏时不需要修测试——一重构就碎的测试是测了实现细节,不是测了行为;
- 测试抖了按检修单查:种子、远程调用、时钟、休眠、泄漏、端口、路径、状 态、顺序;
- 最值钱的一招是依赖注入:把时钟和随机数变成参数——测试稳定了,代码也灵活了;
- 单元测试是你自己的责任,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(搜「执行顺序」) |