🛠️ 第9章 场景一:接手陌生代码库
📄 一句话概括

遗留项目的分水岭是「先验再改」,常规项目是「先改再验」。 特征测试不评价对错,只把现有行为钉住——包括那些看起来像 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 秒实验

  1. 找一个你「不敢动」的老函数,写三条特征测试。 每条都先按直觉写,跑一遍——挂的那几条就是你不知道的行为。
  2. 把你项目里所有 //、round()、int() 的地方搜一遍。 每一处都可能是一个「跟直觉不一样」的地方。
  3. 挑一份你写过的文档,找三条「应该怎样」的描述,回去实测一遍。 有一条不符,就说明那份文档该重写了。

常见误区

  1. 「特征测试是在给 bug 做背书。」 不是。它记录现实,不评价现实。 先把现实钉住,才有资格改现实——否则你分不清「改好了」和「改坏了」。
  2. 「老代码看不懂就先写文档,边写边理。」 写出来的是印象,不是事实。 Demo 里三条印象全被实测推翻。
  3. 「遗留项目就是技术债,早晚要重写。」 重写之前也得先知道现在在干什么。 特征测试是重写的前置条件,不是替代品。
自测三问(点开对答案) 1. 遗留项目和常规项目的分水岭是什么? —— **顺序:常规是「先改再验」,遗留必须「先验再改」。** **因为遗留项目没有回头路。** 2. 特征测试为什么允许断言「不对」的行为? —— **因为它记录现实,不纠正现实。** **先钉住,才能分清「改好了」和「改坏了」。** 3. 为什么先写文档的准确率会这么低? —— **因为写的是印象,不是实测。** **而遗留项目的约定往往跟常识不一样**——整除、浮点、空值处理都是。

第 9 章到这里收住。下一章进场景二:修 bug、补测试、做回归。 从「一份能用的 bug 报告长什么样」开始。

相关笔记