20、TDD与遗留代码:为遗留代码编写特性测试,逐步引入TDD
说实话,很多团队引入TDD最大的障碍不是技术,而是那些已经跑了好几年的遗留代码。你想想看,一个几十万行的老项目,没有测试,谁敢动?我见过太多人一上来就想给整个系统写单元测试,结果写了一周就放弃了——因为根本跑不起来。
那怎么办?我的答案是:先别急着写单元测试,先写特性测试(Characterization Test)。说白了,就是先把代码当前的行为“固化”下来,然后再谈重构和TDD。
什么是特性测试?
特性测试和普通的单元测试有个本质区别:单元测试验证的是“应该怎样”,特性测试记录的是“现在是怎样”。你不需要知道代码对不对,你只需要知道它现在跑出来什么结果。
我在一个金融项目里遇到过这种情况:一个计算利息的函数,没人知道它的逻辑对不对,但所有人都不敢改。后来我写了个特性测试,把输入输出全部记录下来,然后告诉团队:“这就是当前的行为,你们看看是不是想要的。”结果发现有两个边界条件算错了,但大家终于敢动手修了。
编写特性测试的步骤
我个人习惯分四步走,每一步都有明确的目标:
- 识别测试边界——找到函数的输入参数和返回值
- 生成测试数据——用真实数据或随机数据覆盖各种情况
- 记录当前输出——把运行结果作为预期值写进测试
- 固化测试用例——让测试成为回归测试的一部分
举个例子,假设我们有一个遗留的订单折扣计算函数:
// 遗留代码,没人知道它怎么算的
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写真正的单元测试
知识体系图
下面这张图展示了从遗留代码到TDD的完整路径:
实际案例:一个支付模块的改造
我记得有个项目,支付模块的代码有3000多行,全是面条式代码。团队想引入TDD,但根本无从下手。我是这么做的:
- 先找出模块的入口函数(大概有5个)
- 为每个入口函数写特性测试,覆盖所有可能的输入组合
- 跑测试,记录输出,固化测试用例
- 开始重构:每次只提取一个子函数,用特性测试验证
- 等代码清晰后,用TDD写新的单元测试
整个过程花了大概两周。两周后,这个模块的测试覆盖率从0%到了85%,而且团队终于敢改代码了。
总结
特性测试是TDD和遗留代码之间的桥梁。它不需要你理解代码的全部逻辑,只需要你记录当前行为。有了这个安全网,你才能放心地重构,然后逐步引入TDD。
说白了,就是先“认怂”,承认自己看不懂代码,先把行为记录下来。等有了测试保护,再慢慢把代码改好。这比一上来就写单元测试要实际得多。