遗留项目的分水岭是「先验再改」,常规项目是「先改再验」。 特征测试不评价对错,只把现有行为钉住——包括那些看起来像 bug 的行为。 先补验证再写文档,文档准确率从零变成满;先写文档,三条断言全被实测推翻。
这一章四个场景里的第一个收尾。 前面三篇讲的是「怎么在陌生仓库里干活」,这一篇讲「为什么这里不能按常规干」。 遗留项目的特殊之处不在代码老,在「你不知道谁在依赖它」。 所以任何一次改动都可能是破坏性的,而你没有办法提前知道。 Demo 拿一个二十几行的老模块,先按直觉写三条断言(全挂), 再改成钉住实际行为的五条特征测试(全过)。 差别不在测试写得好不好,在「先验还是先写」。
一、遗留项目的三个特殊约束
| 约束 | 常规项目 | 遗留项目 |
|---|---|---|
| 能不能改 | 改了就好 | 改了可能有人依赖 |
| 验证从哪来 | 现成的测试 | 先补特征测试 |
| 文档写给谁 | 未来的自己 | 接手的人,且他信文档 |
第二行是分水岭。 常规项目里,测试是「改完跑一下」; 遗留项目里,测试是「改之前先建」。 顺序反了,就没有回头路。 第一行的难处在于「不知道谁在依赖」。 代码里搜不到调用,不代表没人用—— 可能是别的仓库在调你的 HTTP 接口,可能是有人手工跑这个脚本, 可能是某个 Excel 里粘着导出结果的格式。 第三行最容易被低估。 遗留项目的文档读者不是「未来的自己」,是「明天就要改这块的陌生人」。 而陌生人会信文档——所以一份错的文档在这里的破坏力,比在常规项目里大得多。
二、先补验证,再补文档
「特征测试」这个词听起来玄,其实就是:把现在跑出来的结果记下来,当断言。
它不判断对错,只判断「变了没有」。
Demo 里先按「应该怎样」写了三条断言,全挂。
不是代码有 bug,是「我以为的行为」和「实际行为」不一样:
空列表不返回 0 而是抛 ZeroDivisionError;
平均值用整除,一百点五和两百平均出来是一百五,不是一百五十点二五。
接着改成钉住实际行为,五条全过。
其中一条写的是「total([0.1,0.2]) 不等于 0.3」——
断言「不等于」很别扭,但它是真的,因为浮点累加有尾差。
这就是特征测试的样子:难看,但准。
它的价值不在证明代码对,在让「改坏了」这件事有个判据。
有了它,之后任何一次修改都能立刻知道有没有动到行为——
包括那些你本来就想改的行为,也会被显式地暴露出来。
然后才写文档。
这时候文档变成「抄实测结果」,准确率是满的。
Demo 里那两条「我以为的行为」在文档里根本不存在——因为它们从来就没发生过。
三、为什么这个顺序不能反
反过来的代价是可以算的。 先写文档,你写的是「印象」; 先补验证,你写的是「实测」。 印象和实测的差距,Demo 里是三条全错。 更麻烦的是这个错的传播方式。 文档错了,下一个人照着改,改出问题,然后他不再信文档—— 从此这块代码又回到「没人敢动」的状态。 一次错的文档,会把之前所有的文档工作一起作废。 所以顺序不只是效率问题,是信任问题。 遗留项目里最稀缺的资源不是时间,是「敢动」这件事。 特征测试和实测文档,是唯一能把「敢动」重新建立起来的东西。 一句话总结这一章: 在陌生代码库里,Harness 的第一件事不是让 Agent 干活,是让它先能证明自己没干坏事。
四、本章小结
| 问题 | 答案在哪一节 | 一句话 |
|---|---|---|
| 怎么读陌生仓库 | 9.1 | 切扇区,只要摘要 |
| 怎么给定规矩 | 9.2 | 只写能验证的 |
| 动手前做什么 | 9.3 | 四道题加复述 |
| 改完怎么交 | 9.4 | 三个数字贴着下限 |
| 遗留项目特殊在哪 | 9.5 | 先验再改,先验再写 |
五篇连起来是一条线:看懂、定规矩、验理解、控范围、补证据。 这五步没有一步是「写代码」——但少了任何一步,写出来的代码都要返工。
五、动手 Demo:遗留项目:先补验证,再补文档
一个二十几行的老模块,三个函数。谁都知道它有点怪,没人说得清怪在哪。
先按「应该怎样」写三条断言,再改成钉住实际行为的五条特征测试。
两步都是真跑:断言真执行,unittest 真跑,输出是真结果。
# 特征测试:不评价对错,只把现有行为钉住
class T(unittest.TestCase):
def test_avg_empty_raises(self):
with self.assertRaises(ZeroDivisionError):
avg([])
def test_avg_is_floor(self):
self.assertEqual(avg([100.5, 200.0]), 150.0)
def test_total_float_noise(self):
self.assertNotEqual(total([{"amount": 0.1},
{"amount": 0.2}]), 0.3)
实跑输出:
--------------------------------------------------------------------
遗留项目:先补验证,再补文档
--------------------------------------------------------------------
① 一个没人敢动的老模块
--------------------------------------------------------------------
三个函数,二十几行。谁都知道它有点怪,但没人说得清怪在哪。
② 先按「应该怎样」写三条断言
--------------------------------------------------------------------
挂 avg(空) 应返回 0 -> 抛了 ZeroDivisionError
挂 avg([100.5,200.0]) 应返回 150.25 -> False
挂 total 应精确到分 -> False
三条全挂。这不是代码有 bug,是「我以为的行为」和「实际行为」不一样。
③ 改成钉住实际行为
--------------------------------------------------------------------
avg([]) 实际 抛了 ZeroDivisionError
avg([100.5,200.0]) 150.0
total([0.1,0.2]) 0.30000000000000004
total([]) 0.0
! 四条实测行为,两条跟直觉不一样:空列表不返回 0 而是抛错;
平均值是整数除法,100.5 和 200.0 平均出来是 150.0 不是 150.25。
五条特征测试:OK
test_total_float_noise 那条断言的是「不等于 0.3」——写起来别扭,但它是真的。
! 特征测试的用处不是证明代码对,是让「改坏了」这件事有个判据。
有了它,之后任何一次修改都能立刻知道有没有动到行为。
④ 两种顺序的文档准确率
--------------------------------------------------------------------
| 顺序 | 写出来的东西 | 被实测推翻 | 后果 |
|---|---|---|---|
| 先写文档(照直觉) | 3 条断言 | 3 | 三条全错,得重来 |
| 先补验证再写文档 | 5 条特征测试 | 0 | 描述的就是实测行为 |
先把行为钉住,再写文档,文档就变成「抄实测结果」——这是唯一一种不会过时的写法。
按这个比例推,一份十二条的文档大概会错五条。这个「五」是推的,不是实测——但方向不会错。
⑤ 遗留项目的三个特殊约束
--------------------------------------------------------------------
| 约束 | 常规项目 | 遗留项目 |
|---|---|---|
| 能不能改 | 改了就好 | 改了可能有人依赖 |
| 验证从哪来 | 现成的测试 | 先补特征测试 |
| 文档写给谁 | 未来的自己 | 接手的人,且他信文档 |
第二行是分水岭:常规项目是「先改再验」,遗留项目必须「先验再改」。因为没有验,改就是赌博。
第一段那三条全挂,挂得很有代表性。 一条是「空列表返回零」,实际抛异常; 一条是「平均是精确的」,实际是整除; 一条是「浮点累加精确到分」,实际有尾差。 三条都是「大多数语言默认这样」,而这个模块三条都不这样。 所以遗留项目里「按常识推断」的命中率极低。 不是因为它写错了,是因为它写于另一个年代、另一套约定。 整除在 Python 2 里是默认行为,浮点精度问题在金额计算里是老毛病。 第二段的五条全过,其中一条断言「不等于 0.3」。 这条最值得看:它在断言一件「不对」的事。 但特征测试就该这样——它记录现实,不纠正现实。 要纠正,是另一个提交的事,而且得先有这条测试兜着。 最后那张表两行:先写文档三条全错,先补验证五条全对。 「十二条大概错五条」这个数是从前面推的,不是实测—— 但它说明了一件事:错的文档不是偶发,是默认结果。
30 秒实验
- 找一个你「不敢动」的老函数,写三条特征测试。 每条都先按直觉写,跑一遍——挂的那几条就是你不知道的行为。
- 把你项目里所有
//、round()、int()的地方搜一遍。 每一处都可能是一个「跟直觉不一样」的地方。 - 挑一份你写过的文档,找三条「应该怎样」的描述,回去实测一遍。 有一条不符,就说明那份文档该重写了。
常见误区
- 「特征测试是在给 bug 做背书。」 不是。它记录现实,不评价现实。 先把现实钉住,才有资格改现实——否则你分不清「改好了」和「改坏了」。
- 「老代码看不懂就先写文档,边写边理。」 写出来的是印象,不是事实。 Demo 里三条印象全被实测推翻。
- 「遗留项目就是技术债,早晚要重写。」 重写之前也得先知道现在在干什么。 特征测试是重写的前置条件,不是替代品。
自测三问(点开对答案)
1. 遗留项目和常规项目的分水岭是什么? —— **顺序:常规是「先改再验」,遗留必须「先验再改」。** **因为遗留项目没有回头路。** 2. 特征测试为什么允许断言「不对」的行为? —— **因为它记录现实,不纠正现实。** **先钉住,才能分清「改好了」和「改坏了」。** 3. 为什么先写文档的准确率会这么低? —— **因为写的是印象,不是实测。** **而遗留项目的约定往往跟常识不一样**——整除、浮点、空值处理都是。第 9 章到这里收住。下一章进场景二:修 bug、补测试、做回归。 从「一份能用的 bug 报告长什么样」开始。
- ☐ (本篇完,下一篇 10.1 复现问题:从错误日志到结构化的 bug 报告)