14、测试驱动重构:如何安全地重构遗留代码?TDD如何保证重构不引入bug?
重构遗留代码,说实话,是很多C++工程师的噩梦。
我见过太多团队,一听到「重构」两个字就头皮发麻。不改吧,代码烂成一锅粥;改吧,又怕改出线上事故。我自己早年也踩过这个坑——有一次重构一个支付模块,自认为逻辑很清楚,结果上线后用户余额对不上,回滚花了整整一个通宵。
那之后我就在想:有没有一种方法,能让重构变得安全、可控?
答案是:测试驱动重构。
14.1 什么是测试驱动重构?
测试驱动重构,说白了就是:先给代码穿上「防弹衣」,再动手改造它。
这个防弹衣就是——测试。你想想看,如果有一组自动化测试能覆盖你即将修改的每一行代码,你还怕改出bug吗?
我个人的习惯是:重构之前,先问自己三个问题:
- 这段代码有没有测试?
- 测试够不够细?
- 测试能不能快速运行?
如果答案都是「否」,那对不起,先别急着重构。先补测试。
核心原则:重构 ≠ 重写。重构是在不改变外部行为的前提下,改善内部结构。而测试,就是验证「外部行为没变」的唯一手段。
14.2 遗留代码的「安全气囊」:特性测试
面对遗留代码,我们往往没有现成的单元测试。怎么办?
我的做法是:先写特性测试(Characterization Test)。
什么是特性测试?就是「我不知道这段代码对不对,但我先把它现在的行为记录下来」。你运行一次,把输出结果固化到测试用例里。这样,你重构时只要保证输出不变,就不会出大问题。
// 遗留代码:一个计算折扣的函数,逻辑混乱,没有测试
double calculateDiscount(double price, int quantity, bool isVIP) {
double discount = 0.0;
if (quantity > 10) {
discount = 0.1;
if (isVIP) {
discount = 0.15;
}
} else {
if (isVIP) {
discount = 0.05;
}
}
if (price > 1000) {
discount += 0.05;
}
return price * (1 - discount);
}
// 特性测试:先记录当前行为
TEST(DiscountTest, Characterization) {
// 运行一次,记录结果
EXPECT_DOUBLE_EQ(calculateDiscount(100, 5, false), 100.0);
EXPECT_DOUBLE_EQ(calculateDiscount(100, 15, false), 90.0);
EXPECT_DOUBLE_EQ(calculateDiscount(100, 15, true), 85.0);
EXPECT_DOUBLE_EQ(calculateDiscount(2000, 5, false), 1900.0);
EXPECT_DOUBLE_EQ(calculateDiscount(2000, 15, true), 1600.0);
}
嗯,这里要注意:特性测试不保证「正确」,它只保证「不变」。但这就够了——至少你重构时不会莫名其妙改出不一样的结果。
14.3 重构的「三步走」策略
我个人总结了一套重构流程,用了很多年,效果不错:
- 第一步:写测试,锁定行为 —— 用特性测试或单元测试,把当前代码的行为「钉死」。
- 第二步:小步重构,每步都跑测试 —— 每次只改一点点,改完就跑测试。测试全绿,继续下一步。
- 第三步:清理测试,提升质量 —— 重构完成后,把特性测试改成真正的单元测试,验证逻辑的正确性。
小技巧:我习惯用「红-绿-重构」这个节奏。先让测试变红(确认测试能检测到变化),再让测试变绿(确认修改后行为一致),最后重构代码。每一步不超过5分钟。
14.4 安全重构的「避坑指南」
我曾经犯过一个错误:重构时一次性改了太多东西。改完发现测试挂了,但根本不知道是哪里出的问题。
后来我学乖了。这里分享几个我踩过的坑:
- 不要同时重构和加功能 —— 重构就是重构,加功能就是加功能。混在一起,出了问题你分不清是重构引入的还是新功能引入的。
- 不要相信「肉眼审查」 —— 你以为自己改对了,但编译器不会骗你,测试不会骗你。让测试说话。
- 不要跳过「小步提交」 —— 每次修改后都提交代码,哪怕只是改了一个变量名。这样万一出问题,回滚成本极低。
警告:重构遗留代码时,最危险的心态就是「这段代码很简单,我直接改,不用写测试」。我见过太多人因为这个想法付出惨痛代价。记住:你觉得简单,是因为你还没发现它的隐藏依赖。
14.5 TDD如何保证重构不引入bug?
这个问题其实很简单:TDD通过「即时反馈」来防止bug。
你想想看,传统开发流程是:写代码 → 编译 → 部署 → 测试 → 发现bug → 修复。这个周期可能是几天甚至几周。而TDD的周期是:写测试 → 写代码 → 跑测试 → 发现bug → 修复。这个周期只有几分钟。
周期越短,bug越少。为什么?因为你在代码「刚出生」的时候就发现了问题,而不是等它「长大成人」了再去抓。
具体到重构场景,TDD提供了三层保护:
| 保护层 | 作用 | 举例 |
|---|---|---|
| 第一层:编译期检查 | 类型错误、接口变更 | 改了函数签名,调用方编译不过 |
| 第二层:单元测试 | 逻辑错误、边界条件 | 改了排序算法,测试发现结果不对 |
| 第三层:集成测试 | 模块间交互错误 | 改了数据格式,下游解析失败 |
这三层保护叠加起来,基本上能挡住99%的bug。剩下的1%,说实话,靠的是经验和对业务的理解。
14.6 一个完整的重构案例
我们来看一个真实的例子。假设有一段遗留代码,功能是计算订单总价:
// 遗留代码:订单计算,逻辑混乱
double calculateOrderTotal(std::vector<Item> items, std::string coupon) {
double total = 0;
for (auto& item : items) {
total += item.price * item.quantity;
}
if (coupon == "SAVE10") {
total = total * 0.9;
} else if (coupon == "SAVE20") {
total = total * 0.8;
}
if (total > 200) {
total = total - 20;
}
return total;
}
这段代码的问题很明显:优惠券逻辑和满减逻辑混在一起,难以维护。我们想把它重构得更清晰。
第一步:写特性测试
TEST(OrderTest, Characterization) {
std::vector<Item> items = {{100, 2}, {50, 1}}; // 总价250
EXPECT_DOUBLE_EQ(calculateOrderTotal(items, ""), 230.0); // 250 - 20
EXPECT_DOUBLE_EQ(calculateOrderTotal(items, "SAVE10"), 207.0); // 250*0.9 - 20
EXPECT_DOUBLE_EQ(calculateOrderTotal(items, "SAVE20"), 180.0); // 250*0.8 - 20
}
第二步:小步重构
我先提取优惠券计算逻辑:
double applyCoupon(double total, const std::string& coupon) {
if (coupon == "SAVE10") return total * 0.9;
if (coupon == "SAVE20") return total * 0.8;
return total;
}
跑测试,全绿。再提取满减逻辑:
double applyFreeShipping(double total) {
return (total > 200) ? total - 20 : total;
}
跑测试,全绿。最后组合起来:
double calculateOrderTotal(std::vector<Item> items, std::string coupon) {
double total = 0;
for (auto& item : items) {
total += item.price * item.quantity;
}
total = applyCoupon(total, coupon);
total = applyFreeShipping(total);
return total;
}
跑测试,全绿。重构完成。
第三步:清理测试
现在我们可以把特性测试改成真正的单元测试,验证每个函数的正确性:
TEST(CouponTest, ApplyCoupon) {
EXPECT_DOUBLE_EQ(applyCoupon(100, "SAVE10"), 90.0);
EXPECT_DOUBLE_EQ(applyCoupon(100, "SAVE20"), 80.0);
EXPECT_DOUBLE_EQ(applyCoupon(100, ""), 100.0);
}
TEST(ShippingTest, ApplyFreeShipping) {
EXPECT_DOUBLE_EQ(applyFreeShipping(250), 230.0);
EXPECT_DOUBLE_EQ(applyFreeShipping(100), 100.0);
}
整个过程,我没有引入任何bug。为什么?因为每一步都有测试在「盯着」我。
14.7 知识体系总览
下面这张图总结了我对「测试驱动重构」的理解:
14.8 写在最后
测试驱动重构,说白了就是「胆小的人」的生存之道。我承认,我胆小。我不敢在没有测试保护的情况下改代码。但正是这种「胆小」,让我在多年的C++开发中几乎没有因为重构出过线上事故。
你可能会觉得写测试很麻烦。但你想过没有:修复一个线上bug的成本,是写测试的10倍甚至100倍。这笔账,算清楚之后,你就知道该怎么选了。
记住这句话:重构的速度,取决于你测试的速度。测试越快,重构越安全。