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)是专门给遗留代码用的。它的思路是:先不管代码对不对,先记录它当前的行为,然后把这些行为写成测试。
步骤很简单:
- 随便给几个输入
- 运行代码,记录输出
- 把输出写成断言
- 标记为
@Failing或@Wip(Work in Progress)
为什么要这么做?因为遗留代码往往没有文档,你根本不知道它「应该」输出什么。特性测试就像给代码拍了一张快照,告诉你「现在它是这么跑的」。以后你重构时,只要保证输出不变就行。
我有个项目就是靠这个办法救回来的。一个 2000 行的报表生成类,没人敢动。我花了三天写了 40 个特性测试,然后才敢开始重构。嗯,虽然慢,但稳。
SVG:遗留代码测试策略全景图
避坑指南:我踩过的几个雷
- 不要为了测试而修改生产代码的可见性。把 private 改成 public 只是为了测试?千万别。这会让其他开发者误以为这个方法是可以随便调用的。
- 特性测试要标记清楚。我习惯在测试名里加
_characterization后缀,比如testGenerate_characterization。这样一看就知道这个测试是「记录行为」而不是「验证正确行为」。 - 遗留代码的测试不要追求覆盖率。先覆盖核心路径,分支覆盖慢慢来。我见过有人一上来就追求 80% 覆盖率,结果测试写了一堆,代码还是不敢改——因为测试本身就有 bug。
- 提取方法时注意副作用。有些遗留代码的方法内部会修改全局状态,提取出来之后,测试里要记得重置状态。我吃过这个亏,一个测试跑完,下一个测试就挂了,排查了半天才发现是静态变量没清理。
小结
测试私有方法和遗留代码,说白了就是两件事:找到接缝 和 小步前进。反射是应急手段,提取方法是长期方案,特性测试是安全网。别想着一步到位,先让代码变得可测试,再慢慢优化。
我个人习惯是:每次接手遗留模块,先花 20% 的时间加特性测试,再花 30% 的时间提取接缝,最后 50% 的时间做真正的重构。这个比例不一定适合所有人,但至少能保证你不会一上来就被代码吓住。