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 重构的「三步走」策略

我个人总结了一套重构流程,用了很多年,效果不错:

  1. 第一步:写测试,锁定行为 —— 用特性测试或单元测试,把当前代码的行为「钉死」。
  2. 第二步:小步重构,每步都跑测试 —— 每次只改一点点,改完就跑测试。测试全绿,继续下一步。
  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 知识体系总览

下面这张图总结了我对「测试驱动重构」的理解:

测试驱动重构知识体系 测试驱动重构 特性测试 小步重构 即时反馈 记录当前行为 不验证正确性 每次只改一点点 改完就跑测试 几分钟内发现bug 降低修复成本 目标:安全重构,零bug引入

14.8 写在最后

测试驱动重构,说白了就是「胆小的人」的生存之道。我承认,我胆小。我不敢在没有测试保护的情况下改代码。但正是这种「胆小」,让我在多年的C++开发中几乎没有因为重构出过线上事故。

你可能会觉得写测试很麻烦。但你想过没有:修复一个线上bug的成本,是写测试的10倍甚至100倍。这笔账,算清楚之后,你就知道该怎么选了。

记住这句话:重构的速度,取决于你测试的速度。测试越快,重构越安全。


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