20、TDD与遗留代码:为遗留代码编写特性测试,逐步引入TDD

说实话,很多团队引入TDD最大的障碍不是技术,而是那些已经跑了好几年的遗留代码。你想想看,一个几十万行的老项目,没有测试,谁敢动?我见过太多人一上来就想给整个系统写单元测试,结果写了一周就放弃了——因为根本跑不起来。

那怎么办?我的答案是:先别急着写单元测试,先写特性测试(Characterization Test)。说白了,就是先把代码当前的行为“固化”下来,然后再谈重构和TDD。

什么是特性测试?

特性测试和普通的单元测试有个本质区别:单元测试验证的是“应该怎样”,特性测试记录的是“现在是怎样”。你不需要知道代码对不对,你只需要知道它现在跑出来什么结果。

我在一个金融项目里遇到过这种情况:一个计算利息的函数,没人知道它的逻辑对不对,但所有人都不敢改。后来我写了个特性测试,把输入输出全部记录下来,然后告诉团队:“这就是当前的行为,你们看看是不是想要的。”结果发现有两个边界条件算错了,但大家终于敢动手修了。

核心思想:特性测试不是为了证明代码正确,而是为了捕捉当前行为,为后续重构提供安全网。

编写特性测试的步骤

我个人习惯分四步走,每一步都有明确的目标:

  1. 识别测试边界——找到函数的输入参数和返回值
  2. 生成测试数据——用真实数据或随机数据覆盖各种情况
  3. 记录当前输出——把运行结果作为预期值写进测试
  4. 固化测试用例——让测试成为回归测试的一部分

举个例子,假设我们有一个遗留的订单折扣计算函数:

// 遗留代码,没人知道它怎么算的
double calculateDiscount(double amount, int customerLevel) {
    // 几百行逻辑,各种if-else嵌套
    // 还有几个全局变量
    // ...
    return result;
}

我们这样写特性测试:

TEST(CharacterizationTest, CalculateDiscount_CurrentBehavior) {
    // 第一步:准备输入
    double amount = 1000.0;
    int level = 2;
    
    // 第二步:调用函数,记录输出
    double result = calculateDiscount(amount, level);
    
    // 第三步:把当前输出作为预期值
    // 注意:这里不是验证正确性,而是记录行为
    EXPECT_DOUBLE_EQ(result, 85.0);
    
    // 再测几个边界值
    EXPECT_DOUBLE_EQ(calculateDiscount(0.0, 1), 0.0);
    EXPECT_DOUBLE_EQ(calculateDiscount(100.0, 0), 5.0);
    EXPECT_DOUBLE_EQ(calculateDiscount(5000.0, 3), 450.0);
}
我的经验:第一次跑特性测试时,大概率会失败。别慌,把实际输出值复制到预期值里就行。这不是作弊,这是在“学习”代码的行为。

从特性测试过渡到TDD

有了特性测试这个安全网,我们就可以开始重构了。但这里有个关键点:不要一次性重构太多。我建议每次只改一个函数,改完就跑特性测试,确保行为没变。

等代码变得清晰了,再开始用TDD写新的单元测试。这时候你会发现,TDD变得容易多了——因为特性测试已经帮你把边界条件都摸清楚了。

为什么会这样?因为特性测试本质上是在做“逆向工程”。你不需要理解代码的全部逻辑,只需要知道输入输出。这就像你不需要知道发动机怎么造,但你知道踩油门车会走。

避坑指南

我曾经踩过一个坑:给一个函数写了特性测试,然后重构时发现测试全绿,但线上出bug了。后来排查发现,那个函数依赖了一个全局状态,而我的测试没有覆盖到那个状态。

所以,写特性测试时要注意:

  • 覆盖真实场景——用生产环境的数据来生成测试用例
  • 注意副作用——如果函数修改了全局变量或文件,也要记录下来
  • 不要过度依赖——特性测试只是过渡,最终还是要用TDD写真正的单元测试
重要提醒:特性测试不是银弹。如果代码本身有bug,特性测试也会把bug当作正确行为记录下来。所以,写完特性测试后,一定要找业务专家确认一下关键路径的行为是否正确。

知识体系图

下面这张图展示了从遗留代码到TDD的完整路径:

遗留代码 → TDD 过渡路径 遗留代码 无测试,不敢改 特性测试 记录当前行为 安全重构 特性测试保护 TDD 新功能开发 红-绿-重构循环 单元测试体系 持续集成 可维护的代码库 注:特性测试是过渡手段,最终目标是建立完整的单元测试体系

实际案例:一个支付模块的改造

我记得有个项目,支付模块的代码有3000多行,全是面条式代码。团队想引入TDD,但根本无从下手。我是这么做的:

  1. 先找出模块的入口函数(大概有5个)
  2. 为每个入口函数写特性测试,覆盖所有可能的输入组合
  3. 跑测试,记录输出,固化测试用例
  4. 开始重构:每次只提取一个子函数,用特性测试验证
  5. 等代码清晰后,用TDD写新的单元测试

整个过程花了大概两周。两周后,这个模块的测试覆盖率从0%到了85%,而且团队终于敢改代码了。

关键数据:特性测试阶段写了120个测试用例,重构后保留了80个作为回归测试,新增了60个TDD单元测试。总共140个测试,覆盖了所有核心路径。

总结

特性测试是TDD和遗留代码之间的桥梁。它不需要你理解代码的全部逻辑,只需要你记录当前行为。有了这个安全网,你才能放心地重构,然后逐步引入TDD。

说白了,就是先“认怂”,承认自己看不懂代码,先把行为记录下来。等有了测试保护,再慢慢把代码改好。这比一上来就写单元测试要实际得多。

最后一个小建议:特性测试的代码质量可以低一些,因为它只是过渡。但一定要保证它跑得起来,而且每次重构前都要跑一遍。这是你的安全网,别让它破了。

公众号:蓝海资料掘金营,微信deep3321