实战案例二:遗留代码系统重构(上)—— 理解代码、编写特性测试

说实话,每次接手遗留代码,我心里都会咯噔一下。不是怕改不动,是怕改完之后,系统不工作了,而我自己还不知道。这就像拆一颗炸弹,剪错一根线,后果很严重。

我个人的习惯是:先别急着动手改,先花时间理解它,再给它穿上“防护服”——特性测试。今天这个案例,我们就来聊聊怎么安全地接近遗留代码。

第一步:理解代码——别当“盲人摸象”

你想想看,一段代码跑了五六年,没人敢动,为什么?因为没人真正理解它。我见过一个项目,核心模块有3000行,注释只有三行,还都是错的。

理解遗留代码,我一般分三步走:

  1. 跑起来,看效果 —— 先把项目在本地跑起来。看看它到底是个什么东西。是Web应用?是批处理?还是后台服务?
  2. 找入口,理脉络 —— 找到main函数,或者请求入口。顺着它往下走,画出调用链。别想着一次全看懂,先看主干。
  3. 记笔记,画草图 —— 我个人习惯用纸笔或者思维导图,把关键类、关键方法、数据流向记下来。好记性不如烂笔头。

核心原则: 理解代码不是为了重写它,而是为了知道“改哪里会出问题”。

我曾经接手过一个支付对账系统,代码里有一堆魔法数字和全局变量。我花了整整两天,就干一件事:把每个分支条件都列出来,画成流程图。结果发现,有一个分支逻辑,连原来的开发人员都记不清了。嗯,这就是典型的“遗留代码陷阱”。

第二步:编写特性测试——给代码穿上“防护服”

理解完代码,下一步就是写测试。但注意,这里不是写单元测试,而是写特性测试(Characterization Test)

什么叫特性测试?说白了,就是把代码当前的行为“固化”下来。不管它是对是错,我们先把它现在的输出记录下来。这样,我们重构的时候,只要输出不变,就说明没改坏。

写特性测试的步骤:

  1. 选一个函数/方法 —— 从最底层、最独立的函数开始。比如一个工具类方法,或者一个纯计算函数。
  2. 构造输入 —— 随便给几个典型的输入值。边界值、正常值、异常值,都来一点。
  3. 记录输出 —— 运行代码,把实际输出记下来。这就是你的“预期结果”。
  4. 写成断言 —— 把输入和输出写成测试用例。用 assertEquals 之类的断言。

举个例子,假设有一段这样的遗留代码:

public class DiscountCalculator {
    public double calculate(double amount, String customerType) {
        double discount = 0;
        if (customerType.equals("VIP")) {
            discount = 0.2;
        } else if (customerType.equals("NORMAL")) {
            discount = 0.1;
        } else {
            discount = 0.05;
        }
        // 这里有个隐藏逻辑:金额超过1000,额外打9折
        if (amount > 1000) {
            discount = discount * 0.9;
        }
        return amount * (1 - discount);
    }
}

我们不知道这个逻辑对不对,但我们可以先写特性测试:

@Test
public void testCalculate_VIP_Amount500() {
    DiscountCalculator calc = new DiscountCalculator();
    double result = calc.calculate(500, "VIP");
    // 运行一次,得到实际结果,假设是 400.0
    assertEquals(400.0, result, 0.001);
}

@Test
public void testCalculate_NORMAL_Amount2000() {
    DiscountCalculator calc = new DiscountCalculator();
    double result = calc.calculate(2000, "NORMAL");
    // 运行一次,得到实际结果,假设是 1620.0
    assertEquals(1620.0, result, 0.001);
}

小技巧: 写特性测试时,别纠结“这个逻辑对不对”。先记录,后分析。等测试覆盖全了,再回头审视业务逻辑是否正确。

第三步:构建测试安全网——覆盖关键路径

特性测试不是写一个两个就完事了。你需要构建一张“测试安全网”,把代码的关键路径都覆盖到。

我一般会关注这几个点:

  • 条件分支 —— if-else、switch-case,每个分支至少覆盖一次。
  • 边界值 —— 比如金额为0、负数、最大值。
  • 异常路径 —— 比如传入null、空字符串、非法枚举值。
  • 循环/递归 —— 至少覆盖0次、1次、多次的情况。

下面这张图,展示了特性测试在整个重构流程中的位置:

特性测试在重构流程中的位置 1. 理解代码 跑起来 · 理脉络 · 记笔记 2. 编写特性测试 选函数 · 构造输入 · 记录输出 3. 构建安全网 覆盖分支 · 边界 · 异常 迭代:发现新逻辑 → 补充测试 → 加深理解 4. 开始重构(下一章内容) 在安全网的保护下,小步修改,持续验证

避坑指南:写特性测试时容易犯的错

我刚开始写特性测试时,也踩过不少坑。分享几个典型的:

  • 依赖外部资源 —— 测试里连数据库、调远程API。结果测试跑一次要10分钟,根本没法频繁执行。我的建议是:用Mock或者Stub把外部依赖替换掉。
  • 测试太“脆弱” —— 断言写得太死,比如检查整个JSON字符串。稍微改个字段顺序就挂了。应该只断言关键输出。
  • 只测“快乐路径” —— 只测正常情况,不测异常。结果重构时,异常路径全崩了。记住:遗留代码的bug往往藏在异常路径里

警告: 千万不要在没写特性测试的情况下,直接重构遗留代码。我曾经有一次,自认为看懂了代码,直接动手改,结果改完发现一个隐藏的边界条件没处理,线上出了P0故障。从那以后,我再也不敢跳过特性测试这一步。

总结一下今天的核心要点

步骤 核心动作 产出物
理解代码 跑起来、找入口、画调用链 代码结构笔记、调用关系图
编写特性测试 选函数、构造输入、记录输出 一组可重复执行的测试用例
构建安全网 覆盖分支、边界、异常路径 高覆盖率的测试套件

说白了,这一章就干两件事:看懂它,锁住它。看懂代码的意图,锁住代码当前的行为。有了这两步,你才能放心地去动代码。

嗯,今天就先到这里。下一章,我们会在安全网的保护下,开始真正的重构操作。


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