接手没人看得懂的老代码?特征测试先织一张安全网

接到一个改五年老模块的需求,打开代码:没有测试、注释稀缺、写它的人早已离职,业务规则全藏在层层 if 里。你问同事“这块逻辑到底是干嘛的”,得到的回答是“别乱动,能跑就别碰”。迈克尔·费瑟斯在《修改代码的艺术》里给过一个扎心的定义:遗留代码(legacy code)就是没有测试的代码。它难改,不是因为语法老旧,而是因为没有人知道“改坏了没有”。破局的办法,是动手之前先织一张网——用特征测试把代码当前的“行为”固化下来。

特征测试的思想:先记录“现在怎样”,再判断“应该怎样”

面对没有文档、没人说得清规则的老代码,最大的难题是:你连“正确的行为”是什么都不知道,自然写不出传统的“期望断言”测试。特征测试(characterization test)换了个思路——既然搞不清“应该怎样”,就先如实记录“现在怎样”:用一组真实输入去调用代码,把它的实际输出原样写进断言。测试通过,说明代码行为没变,你的修改是安全的;测试变红,说明行为变了——这时再人工判断:这个变化是需求要的(更新断言),还是改出来的 bug(修复代码)。所以特征测试的本质是一副“行为模具”,它不评判对错,只负责报警。它锁住的是现状,而现状里哪些是 bug、哪些是要保留的规则,需要你拿着测试结果去和产品、业务确认。

第一步:从需求反推影响面,别想一口吃成胖子

不要试图给整个系统补测试——那会耗尽你的耐心。正确做法是从本次需求出发反向定位:这个改动会经过哪些函数、涉及哪些数据结构和外部依赖?用 IDE 的“查看调用层级”顺着调用链摸一遍,也可以让 AI 先做影响面分析(提示它“先列出这次修改可能影响的既有功能、共用组件、API 与测试范围,标出高风险区域,不要急着改代码”)。锁定范围后,优先给“坏味道最集中”的深层函数写测试——它们逻辑最复杂、最容易改坏,也最值得保护。

第二步:写特征测试的三个实操细节

第一,输入要真实。从线上日志、历史工单里找真实调用数据,比拍脑袋构造的输入可靠得多——特征测试的价值就建立在“它确实反映了生产环境行为”之上。第二,先跑通再收紧:第一版断言可以宽松,先把测试跑绿,确认输入输出路径正确后,再把断言精确到返回值、数据库状态、日志等具体输出;断言太宽测不出回归,太窄又会在合法变化时误报。第三,处理依赖用“接缝”:老代码往往硬编码了数据库、外部 API 等依赖,难以在测试里实例化。费瑟斯的方法是在依赖处制造“接缝”——把直接调用改成可注入的接口,测试时替换成替身。如果依赖实在拆不动,退而求其次用测试库数据跑集成式特征测试,也比没有强。

第三步:小步修改,让新旧测试共同守门

特征测试织好之后,修改就变成了“戴着安全网走钢丝”:每改一步,跑一遍相关特征测试,全绿说明行为没变,可以继续;变红就停下来判断是预期变化还是失误。新功能不要只依赖旧测试——为新行为单独补新的测试用例,新旧测试共同构成防护网。配合持续集成与版本控制,任何一步出错都能快速定位并回滚。有人形容这个过程是“先铸模,再雕刻”:特征测试是模具,确认了材料原本的形状,之后的每一次改动都有的放矢。

案例:运费模块的“十八个品类”安全网

一家电商公司要调整运费计价规则,而计价模块是七年前的老代码:三百行嵌套条件,两个写它的人早已离职,谁都不敢碰。工程师没有直接改,而是先用线上真实订单数据,为十八个品类各写了一条特征测试,把当前计算结果全部固化。改完规则后跑测试,十八条里十六条符合预期变化,两条标红——核对后发现,那两条正是系统里存在多年的老 bug(某偏远地区运费计算错误),产品经理确认后顺手修正。整个发布过程零事故,客户无感知。对比隔壁团队的经历:同样改计价规则,跳过测试直接上线,结果老客户账单大面积出错,凌晨紧急回滚,还赔了一轮道歉。同样一摊老代码,有网和没网,是“手术”和“赌博”的区别。

常见误区

  • 误区一:想一次性给整个系统补测试。从本次修改的路径开始,逐步扩大覆盖,比宏大计划更可持续。
  • 误区二:把“现状”当成“正确”。特征测试只锁行为,锁住的老 bug 要主动和产品确认、修正断言。
  • 误区三:依赖外部服务导致测试忽绿忽红。测试不稳定等于没有测试,用替身隔离外部依赖。
  • 误区四:写完特征测试却不敢改代码。网的价值在“走钢丝”时体现,写完就束之高阁等于白织。
  • 误区五:测试跑得慢到没人愿意跑。只覆盖关键路径、保持秒级运行,防护网才会被真正使用。

行动建议

下次接到老模块的需求,把流程定为四步:先花半天从真实数据出发写特征测试、固化当前行为;拿着测试结果与产品确认哪些是 bug、哪些是规则;然后小步修改,每步跑测试;提交时新旧用例全绿。把“无测试不动老代码”写进团队约定——老代码不是不能改,而是要在看得见风险的地方改。

标签:#, #, #