34、重构与单元测试(二):如何测试私有方法、如何测试遗留代码

上一讲我们聊了单元测试的基础套路。今天这个话题,说实话,是很多团队真正开始写测试时第一个卡住的地方——私有方法怎么测?遗留代码怎么测?

我在好几个项目里都遇到过这种场景:代码写了一堆 private 方法,逻辑还挺复杂,但一写测试就傻眼了——根本调不到啊。还有那种几百行的大函数,没有任何依赖注入,全是硬编码,想加个测试简直无从下手。

别急,咱们一个一个拆。

私有方法测试:三种主流方案

先说说私有方法。很多人一上来就问:「私有方法到底要不要测?」我的答案是:如果私有方法里有核心业务逻辑,那它必须被覆盖到。至于怎么测,有几种路子。

方案 做法 适用场景 我个人的偏好
方案一:反射调用 通过 Java 反射或 Python 的 __private 机制直接调用 临时救急、遗留代码快速加测 ⭐ 偶尔用,但不推荐长期依赖
方案二:提取为公有方法 将私有方法拆到新类中,变成 public 方法 逻辑独立、可复用的工具方法 ⭐⭐⭐⭐ 推荐
方案三:通过公有方法间接覆盖 只测公有接口,私有逻辑被公有方法调用时自然覆盖 私有方法逻辑简单、调用链清晰 ⭐⭐⭐⭐⭐ 最推荐

核心原则:测试应该关注行为,而不是实现。私有方法是实现细节,公有方法是行为契约。能通过公有方法覆盖的,就别折腾私有方法。

方案一:反射调用(应急用)

我记得有一次接手一个老项目,核心算法全写在 private 方法里,而且那个方法有 80 行。重构之前必须先加测试,没办法,只能用反射。

Java 里大概长这样:

// 被测类
public class OrderCalculator {
    private double applyDiscount(double price, int level) {
        // 核心折扣逻辑,80行
        if (level > 5) return price * 0.8;
        if (level > 3) return price * 0.9;
        return price;
    }
}

// 测试代码
@Test
public void testApplyDiscount() throws Exception {
    OrderCalculator calc = new OrderCalculator();
    Method method = OrderCalculator.class.getDeclaredMethod("applyDiscount", double.class, int.class);
    method.setAccessible(true);

    double result = (double) method.invoke(calc, 100.0, 6);
    assertEquals(80.0, result, 0.01);
}

我曾经踩过的坑:反射测试在重构时特别脆弱。你改了方法名或者参数类型,测试编译期完全不会报错,运行时才炸。所以我的建议是——反射测试只用来给遗留代码「上保险」,一旦重构完成,立刻换成方案二或方案三。

方案二:提取为公有方法(推荐)

说白了,如果一个私有方法逻辑复杂到需要单独测试,那它大概率应该是一个独立的类。你想想看,applyDiscount 这个逻辑,它依赖的是价格和会员等级,跟 OrderCalculator 的其他逻辑其实没什么耦合。

重构后:

// 新类,公有方法
public class DiscountPolicy {
    public double apply(double price, int level) {
        if (level > 5) return price * 0.8;
        if (level > 3) return price * 0.9;
        return price;
    }
}

// 测试就变得很自然
@Test
public void testHighLevelDiscount() {
    DiscountPolicy policy = new DiscountPolicy();
    assertEquals(80.0, policy.apply(100.0, 6), 0.01);
}

这样做的好处是:测试代码干净,业务逻辑也更容易复用。我在项目中做过好几次类似的拆分,每次拆完都感觉代码「透气」了。

方案三:通过公有方法间接覆盖

如果私有方法只是公有方法内部的一个步骤,而且逻辑不复杂,那就别单独测它。你测公有方法的时候,它自然就被覆盖了。

举个例子:

public class OrderService {
    public double checkout(Order order) {
        double total = order.getTotal();
        total = applyDiscount(total, order.getUserLevel()); // 私有方法
        return total;
    }

    private double applyDiscount(double price, int level) {
        if (level > 5) return price * 0.8;
        return price;
    }
}

测试时只需要测 checkout

@Test
public void testCheckoutWithHighLevelUser() {
    Order order = new Order(100.0, 6);
    OrderService service = new OrderService();
    assertEquals(80.0, service.checkout(order), 0.01);
}

你看,私有方法被间接测到了,而且测试代码关注的是「用户下单结算」这个行为,而不是内部实现细节。以后重构私有方法,测试也不用改。

遗留代码测试:从零开始的破冰策略

好,私有方法的问题聊完了。接下来是更头疼的——遗留代码。几百行的大函数,没有接口,没有依赖注入,全局变量满天飞。怎么测?

我的经验是:不要试图一步到位。先给代码「上套」,再慢慢重构。

第一步:识别接缝(Seam)

什么是接缝?就是你能在不修改代码的情况下改变行为的地方。比如:

  • 方法调用(可以 mock)
  • 构造函数(可以传 mock 对象)
  • 静态方法(可以替换实现)
  • 全局变量(可以临时修改)

我见过最极端的例子:一个函数里直接 new 了数据库连接,然后查表。这种代码没有接缝,怎么办?

第二步:用「提取并重写」技术

这是 Michael Feathers 在《修改代码的艺术》里提出的经典方法。说白了就是:把硬编码的部分提取成 protected 方法,然后在测试类里重写它。

看个例子:

// 遗留代码
public class ReportGenerator {
    public String generate() {
        Connection conn = DriverManager.getConnection("jdbc:...");
        // 查数据库,生成报告...
        return result;
    }
}

// 第一步:提取方法
public class ReportGenerator {
    public String generate() {
        Connection conn = getConnection(); // 提取成 protected 方法
        // 查数据库...
        return result;
    }

    protected Connection getConnection() {
        return DriverManager.getConnection("jdbc:...");
    }
}

// 第二步:在测试中重写
@Test
public void testGenerate() {
    ReportGenerator gen = new ReportGenerator() {
        @Override
        protected Connection getConnection() {
            return mockConnection; // 返回 mock 对象
        }
    };
    String result = gen.generate();
    assertEquals("expected", result);
}

一个小技巧:提取方法时,尽量用 protected 而不是 public。这样既能让测试访问到,又不会破坏封装。我习惯在遗留代码里先加一个 protected 的「测试钩子」,等重构完成后再删掉。

第三步:编写「特性测试」

特性测试(Characterization Test)是专门给遗留代码用的。它的思路是:先不管代码对不对,先记录它当前的行为,然后把这些行为写成测试。

步骤很简单:

  1. 随便给几个输入
  2. 运行代码,记录输出
  3. 把输出写成断言
  4. 标记为 @Failing@Wip(Work in Progress)

为什么要这么做?因为遗留代码往往没有文档,你根本不知道它「应该」输出什么。特性测试就像给代码拍了一张快照,告诉你「现在它是这么跑的」。以后你重构时,只要保证输出不变就行。

我有个项目就是靠这个办法救回来的。一个 2000 行的报表生成类,没人敢动。我花了三天写了 40 个特性测试,然后才敢开始重构。嗯,虽然慢,但稳。

SVG:遗留代码测试策略全景图

遗留代码测试策略全景图 遗留代码 私有方法测试 识别接缝(Seam) 编写特性测试 反射调用 提取为公有方法 公有方法间接覆盖 提取并重写(Extract & Override) 记录当前行为 → 断言 核心原则:先上测试,再重构 不要试图一步到位,小步快跑

避坑指南:我踩过的几个雷

  • 不要为了测试而修改生产代码的可见性。把 private 改成 public 只是为了测试?千万别。这会让其他开发者误以为这个方法是可以随便调用的。
  • 特性测试要标记清楚。我习惯在测试名里加 _characterization 后缀,比如 testGenerate_characterization。这样一看就知道这个测试是「记录行为」而不是「验证正确行为」。
  • 遗留代码的测试不要追求覆盖率。先覆盖核心路径,分支覆盖慢慢来。我见过有人一上来就追求 80% 覆盖率,结果测试写了一堆,代码还是不敢改——因为测试本身就有 bug。
  • 提取方法时注意副作用。有些遗留代码的方法内部会修改全局状态,提取出来之后,测试里要记得重置状态。我吃过这个亏,一个测试跑完,下一个测试就挂了,排查了半天才发现是静态变量没清理。

小结

测试私有方法和遗留代码,说白了就是两件事:找到接缝小步前进。反射是应急手段,提取方法是长期方案,特性测试是安全网。别想着一步到位,先让代码变得可测试,再慢慢优化。

我个人习惯是:每次接手遗留模块,先花 20% 的时间加特性测试,再花 30% 的时间提取接缝,最后 50% 的时间做真正的重构。这个比例不一定适合所有人,但至少能保证你不会一上来就被代码吓住。


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