Java 测试重构实战——如何把遗留代码的 0% 覆盖率改善到 60%+
Java 测试重构实战——如何把遗留代码的 0% 覆盖率改善到 60%+
适读人群:接手了没有测试的遗留代码,不知道从哪里开始补测试的工程师 | 阅读时长:约14分钟 | 核心价值:学会分析遗留代码、制定测试补充策略,一步步把测试覆盖率从零提升到可用水平
接手了一个"裸奔"项目
2023年初,我接手了一个运行了三年的电商系统。之前的开发团队已经解散,只留下了代码和简单的接口文档。
打开项目,运行测试:0 个测试用例。
代码量:约 3 万行业务代码,150+ 个 Service 类,80+ 个 Controller,200+ 个 Repository。
产品同学告诉我:这个系统每月处理 500 万笔订单,出了问题损失是每分钟数万元。
我的任务是:重构部分模块,增加新功能,同时不能破坏现有功能。
在没有任何测试的情况下修改 3 万行代码,我第一反应是:找到最快的路径,先建立一道测试安全网。
第一步:识别高风险代码
在有限时间里,不可能把所有代码都覆盖。要找到最值得测试的代码:
识别维度一:业务价值高
- 订单创建、支付处理、库存扣减——这些出错,钱就没了
- 用户认证、权限校验——出错,数据安全问题
- 价格计算、折扣逻辑——出错,财务损失
识别维度二:修改频率高
用 git 分析代码修改历史:
# 找出过去1年里修改最多的文件
git log --since="1 year ago" --name-only --pretty=format: | \
grep "\.java$" | sort | uniq -c | sort -rn | head 20修改频率高的代码,未来被改动的可能性也高,出 Bug 的风险最大。
识别维度三:圈复杂度高
用 Maven 的 PMD 插件分析圈复杂度:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId>
<configuration>
<rulesets>
<ruleset>/category/java/design.xml/CyclomaticComplexity</ruleset>
</rulesets>
</configuration>
</plugin>圈复杂度 > 10 的方法,每一个分支都是潜在的 Bug。
第二步:写特征测试(Characterization Tests)
在重构之前,先给当前行为写测试。这类测试不验证"正确的行为",而是"当前的行为"——哪怕当前有 Bug,也先记录下来。
目的是:保证重构不意外改变任何现有行为。
// 特征测试:记录当前行为,不关心是否"正确"
@Test
void characterize_calculateOrderTotal_currentBehavior() {
// 发现当前代码:优惠券折扣计算有问题,
// 当优惠券面值大于订单金额时,返回了负数而不是0
Order order = Order.builder()
.amount(new BigDecimal("50"))
.couponDiscount(new BigDecimal("100"))
.build();
BigDecimal total = orderService.calculateTotal(order);
// 记录当前行为(负数),即使这是个 Bug
// 加注释说明这是需要修复的,但先记录当前状态
assertEquals(new BigDecimal("-50"), total,
"当前行为:优惠券超过订单金额时返回负数(这是已知 Bug,待修复)");
}之后修复 Bug 时,把特征测试更新为期望的正确行为。
第三步:攻破不可测代码
遗留代码里最难测试的几种模式和对策:
问题一:Service 里直接 new 对象
// 遗留代码:硬编码依赖
public class OrderService {
public void processOrder(Long orderId) {
// 直接 new,无法在测试里替换
EmailClient emailClient = new EmailClient("smtp.example.com", 587);
emailClient.sendConfirmation(order);
}
}解法一(最小侵入):提取工厂方法,再 Override
public class OrderService {
public void processOrder(Long orderId) {
EmailClient emailClient = createEmailClient(); // 提取出来
emailClient.sendConfirmation(order);
}
// protected:子类可以 Override,用于测试
protected EmailClient createEmailClient() {
return new EmailClient("smtp.example.com", 587);
}
}
// 测试里继承并 Override
class OrderServiceTest {
@Test
void testProcessOrder() {
EmailClient mockEmailClient = mock(EmailClient.class);
OrderService service = new OrderService() {
@Override
protected EmailClient createEmailClient() {
return mockEmailClient; // 返回 mock
}
};
service.processOrder(1L);
verify(mockEmailClient).sendConfirmation(any());
}
}解法二(推荐):重构为构造器注入
// 重构后
public class OrderService {
private final EmailClient emailClient;
public OrderService(EmailClient emailClient) {
this.emailClient = emailClient;
}
}
// 测试
@Test
void testProcessOrder() {
EmailClient mockClient = mock(EmailClient.class);
OrderService service = new OrderService(mockClient);
service.processOrder(1L);
verify(mockClient).sendConfirmation(any());
}问题二:静态工具类满天飞
// 遗留代码
public String getOrderStatus(Long orderId) {
String cacheKey = CacheUtils.buildKey("order", orderId);
String cached = RedisUtils.get(cacheKey);
if (cached != null) return cached;
// ...
}短期方案:用 mockito-inline 的 mockStatic 处理(前面的文章讲过)。 长期方案:把静态调用包装成接口,注入进来。
问题三:方法太长(上百行),无法单独测试某个逻辑
// 一个 300 行的 processPayment 方法……
public ProcessResult processPayment(PaymentRequest request) {
// 50行:参数验证
// 50行:查用户信息
// 50行:查订单信息
// 50行:调用支付网关
// 50行:更新订单状态
// 50行:发送通知
}解法:提取方法,让每段逻辑可以独立测试:
public ProcessResult processPayment(PaymentRequest request) {
validateRequest(request); // 可以单独测
User user = loadUser(request); // 可以单独测
Order order = loadOrder(request); // 可以单独测
PaymentResult result = chargePayment(user, order, request); // 可以单独测
updateOrderStatus(order, result); // 可以单独测
sendNotifications(user, order); // 可以单独测
return buildResult(order, result);
}第四步:制定覆盖率提升路线图
从 0% 到 60% 不是一蹴而就的,要分阶段:
第一阶段(1-2周):建立安全网 目标:覆盖率 20%
- 核心业务逻辑:价格计算、状态流转
- 高频修改的方法
第二阶段(2-4周):扩大覆盖 目标:覆盖率 40%
- 所有 Service 层主路径
- 所有 Repository 自定义查询
- 异常处理路径
第三阶段(持续):精细化 目标:覆盖率 60%+
- Controller 层
- 边界条件
- 并发场景
踩坑实录三则
踩坑一:特征测试记录了 Bug 行为,重构时顺手修了 Bug,所有测试失败
现象:重构一个方法时,顺手修了一个空指针 Bug。结果三个特征测试失败了——因为它们记录的是有 Bug 时的行为。
原因:特征测试记录的是"当前行为",修 Bug 改变了行为,测试失败是正确的——这说明你真的改变了行为。
解法:修 Bug 时,把对应的特征测试同步更新为"修复后的正确行为"。这是特征测试的正确工作流:失败 → 检查是否是预期的改变 → 更新测试。
踩坑二:给遗留代码加测试,发现测试本身运行时产生副作用(发邮件、扣款)
现象:写了一个测试,跑了之后发现真的给测试邮箱发了一封邮件,更糟糕的是有一次误触发了真实的支付流程。
原因:遗留代码没有环境隔离,测试环境和生产环境用的是同一个配置,测试里调用了真实的外部服务。
解法:在补测试之前,先建立测试环境隔离:
application-test.yml里配置 mock 的外部服务地址(WireMock)- 所有外部服务(邮件、支付网关、短信)在测试 Profile 里替换为 NoOp 实现
踩坑三:遗留代码里有 ThreadLocal,单元测试里没清理,测试之间数据污染
现象:第一个测试通过,第二个测试因为 ThreadLocal 里有上一个测试留下的数据,断言结果不符合预期。
原因:遗留代码用 ThreadLocal 存请求上下文(用户 ID、租户 ID 等),测试之间没有清理。
解法:
@AfterEach
void cleanupThreadLocals() {
// 清理遗留代码里用到的 ThreadLocal
UserContextHolder.clear();
TenantContextHolder.clear();
RequestContextHolder.resetRequestAttributes();
}最后的建议
给遗留代码补测试是一个长期过程,不要期望几周内能达到理想状态。
但有一条黄金规则值得遵守:每次修改遗留代码,先为修改的方法写测试。不求一次把所有代码都覆盖到,但每次碰到的地方,都留下测试。
三个月后回头看,覆盖率会让你惊喜。
