这个类 780 行,calculatePrice 一个方法 190 行。你想给它补测试,写完 @Test 就卡住了:new 在方法体里,连接串写死在代码里,时间取的是 System.currentTimeMillis()。这不是 JUnit 用得不对,是这段代码压根没有能插入测试替身的地方。
下面这套顺序,我在几个跑了五年以上的 Java 项目里反复用过:先切接缝,再写断言。整个过程调用方零改动,单次 diff 控制在 10 行以内。
先看清楚:这段代码到底卡在哪
把要补测试的那一段摘出来(其余 190 行结构类似):
public class OrderService {
public BigDecimal calculatePrice(long orderId) throws Exception {
// 障碍 1:连接串写死,测试一跑就打真实库
Connection conn = DriverManager.getConnection(
"jdbc:mysql://10.0.0.12:3306/order_db", "order_app", "P@ssw0rd");
BigDecimal price;
try (PreparedStatement ps = conn.prepareStatement(
"select amount, create_time from t_order where id = ?")) {
ps.setLong(1, orderId);
ResultSet rs = ps.executeQuery();
if (!rs.next()) {
throw new IllegalArgumentException("order not found: " + orderId);
}
price = rs.getBigDecimal("amount");
// 障碍 2:直接读系统时间,断言随运行日期漂移
long now = System.currentTimeMillis();
if (now - rs.getTimestamp("create_time").getTime() < 24 * 3600 * 1000L) {
price = price.subtract(new BigDecimal("5.00"));
}
// 障碍 3:静态工具类取配置,类加载时就建好了 Redis 连接
if ("on".equals(ConfigUtil.get("promo.newuser.switch"))) {
price = price.multiply(new BigDecimal("0.95"));
}
}
conn.close();
return price.setScale(2, RoundingMode.HALF_UP);
}
}
四个障碍,对应的其实是同一件事:方法内部直接和外部世界(数据库、配置中心、系统时钟)握了手。没有任何一个点能被测试代码替换掉。
Michael Feathers 在《修改代码的艺术》里把这种可替换点叫接缝(seam)——不改动调用方,就能改变程序行为的位置。老项目补测试,90% 的工作量花在造接缝上,写断言反而是最快的一步。
另外顺手记一笔:上面 conn.close() 没有放在 finally 里,异常路径会泄漏连接。这种问题先记到 issue 里,别在补测试的分支顺手改,混在一起 review 时说不清。
切第一刀:把 new 挪进 protected 方法
最省事的做法不是依赖注入重构,而是加三个 protected 方法,方法体原样搬过去:
public class OrderService {
// 新增:唯一的改动点,调用方一个字节都不用动
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(
"jdbc:mysql://10.0.0.12:3306/order_db", "order_app", "P@ssw0rd");
}
protected long now() {
return System.currentTimeMillis();
}
protected String config(String key) {
return ConfigUtil.get(key);
}
public BigDecimal calculatePrice(long orderId) throws Exception {
Connection conn = getConnection(); // 原来那行 new 换成这句
BigDecimal price;
try (PreparedStatement ps = conn.prepareStatement(
"select amount, create_time from t_order where id = ?")) {
ps.setLong(1, orderId);
ResultSet rs = ps.executeQuery();
if (!rs.next()) {
throw new IllegalArgumentException("order not found: " + orderId);
}
price = rs.getBigDecimal("amount");
long now = now(); // 原来是 System.currentTimeMillis()
if (now - rs.getTimestamp("create_time").getTime() < 24 * 3600 * 1000L) {
price = price.subtract(new BigDecimal("5.00"));
}
if ("on".equals(config("promo.newuser.switch"))) { // 原来是 ConfigUtil.get(...)
price = price.multiply(new BigDecimal("0.95"));
}
}
conn.close();
return price.setScale(2, RoundingMode.HALF_UP);
}
}
几个必须遵守的约束,违反了接缝就白切:
- 方法必须是
protected或包可见,不能是private——private方法 Mockito 覆写不了(除非上mockito-inline的mockConstruction,代价大得多)。 - 方法不能是
final、static,类本身也不能是final。老项目里public final class XxxService很常见,先把final去掉。 - 测试类必须放在同一个包下:
src/test/java/com/example/order/OrderServiceTest.java。不同包访问不到protected。
为什么不直接改成构造器注入?因为那个 Service 在 XML 里被 new 出来、被 @Autowired 注入、被反射创建的地方可能有三四处,改构造器要连带改一堆配置。protected 方法这招 diff 最小,风险最低,等这个类真的要被大改时再做依赖注入重构也不迟。
依赖版本(Java 8 的老项目别上 Mockito 5):
<!-- Java 8 用这套 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>4.11.0</version> <!-- 5.x 要求 Java 11+ -->
<scope>test</scope>
</dependency>
测试侧:spy 打桩,但别用 when(spy.xxx())
改造后的方法没变,仍然是真实逻辑,所以不能 mock(OrderService.class),要用 spy:只覆写那三个方法,其余照跑。
package com.example.order; // 必须同包,否则访问不到 protected
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.sql.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
class OrderServiceTest {
@Test
void price_shouldDiscount_whenWithin24hAndPromoOn() throws Exception {
OrderService svc = spy(new OrderService());
Connection conn = mock(Connection.class);
PreparedStatement ps = mock(PreparedStatement.class);
ResultSet rs = mock(ResultSet.class);
// 关键:给 spy 打桩必须用 doReturn().when(spy),不能用 when(spy.xxx())
// 后者会真的执行一遍 getConnection(),在 CI 上就是直连 10.0.0.12
doReturn(conn).when(svc).getConnection();
doReturn(1_700_000_000_000L).when(svc).now();
doReturn("on").when(svc).config("promo.newuser.switch");
when(conn.prepareStatement(anyString())).thenReturn(ps);
when(ps.executeQuery()).thenReturn(rs);
when(rs.next()).thenReturn(true);
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00"));
// 下单时间 = now - 1 小时
when(rs.getTimestamp("create_time"))
.thenReturn(new Timestamp(1_700_000_000_000L - 3600_000L));
BigDecimal price = svc.calculatePrice(123L);
// 100 - 5 = 95,95 × 0.95 = 90.25
assertEquals(new BigDecimal("90.25"), price);
verify(ps).setLong(1, 123L); // 顺手把入参也断言掉
}
}
doReturn().when(spy) 和 when(spy.xxx()) 的区别是老项目里最容易翻车的一处:后者会真的调用一次被 stub 的方法,用它去打桩 getConnection(),等于测试还没开始就先连了一次生产库,然后 Mockito 才告诉你"stubbing 完成"。症状是测试跑得特别慢、日志里有一堆连接信息,甚至偶发超时。
另一个细节:assertEquals(new BigDecimal("90.25"), price) 用的是 equals,会同时比较 scale。生产返回 90.25(scale 2),new BigDecimal("90.250") 就不相等。拿不准时用 assertEquals(0, expected.compareTo(actual))。
时间是最值得先固定的东西
不固定时间,这个用例在跨年、月末、闰日都可能挂,更隐蔽的是本机跑过、CI 挂——因为 Timestamp 依赖默认时区。测试里除了 doReturn(...).when(svc).now(),把 JVM 时区也钉死:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-Duser.timezone=Asia/Shanghai -Dfile.encoding=UTF-8</argLine>
</configuration>
</plugin>
如果这个项目已经是 Java 8+ 且你打算多改几处,Clock 比 now() 方法更干净:
protected Clock clock() {
return Clock.systemDefaultZone();
}
// 测试里
import java.time.*;
doReturn(Clock.fixed(Instant.parse("2023-11-14T22:13:20Z"), ZoneId.of("Asia/Shanghai")))
.when(svc).clock();
三个真踩过的坑
坑一:静态初始化块会绕过你的 spy
public class ConfigUtil {
private static final Jedis JEDIS;
static {
JEDIS = new Jedis("10.0.0.30", 6379); // 类一加载就建连接
}
public static String get(String key) {
return JEDIS.get(key);
}
}
好消息是,config() 被 spy 覆写后方法体不执行,ConfigUtil 不会被初始化(Java 的类初始化是首次主动使用时触发),这个坑通常碰不到。
坏消息是,如果 OrderService 自己的静态块里调了它:
private static final Map<String, BigDecimal> DISCOUNT = loadFromDb(); // 类加载即执行
那 new OrderService() 还没发生,spy() 也救不了——测试类第一次引用这个类的时候就炸了。这种情况只有一个办法:把它改成懒加载,或者用 mockito-inline 的 mockStatic 在 @BeforeAll 里抢在类加载前打桩。后者在老 JDK 上很容易撞到版本问题:JDK 17 需要 Mockito 4.11+ / ByteBuddy 1.14+,否则报 Unsupported class file major version 61。
坑二:@SpringBootTest 可能在测试机上连生产库
@SpringBootTest // 会加载 src/main/resources/application.properties
class OrderServiceTest { }
如果那份 properties 里写着 spring.datasource.url=jdbc:mysql://10.0.0.12/...、spring.redis.host=10.0.0.30,测试一跑就发真实请求。这是补测试阶段最危险的一件事,比写错断言严重得多。
解法是 src/test/resources/application-test.properties 加 profile:
@ActiveProfiles("test")
@SpringBootTest
class OrderServiceTest {
@BeforeAll
static void guard() {
String url = System.getProperty("spring.datasource.url", "");
assertFalse(url.contains("10.0.0.12"), "测试连到生产库了,停手:" + url);
}
}
更彻底一点,加一个 @BeforeAll 的兜底断言,或者直接在 CI 上用网络策略把生产网段拉黑。别指望每个人都记得改配置。
另外,能写纯 Mockito 单测就别拉 Spring 上下文。同一个模块实测(4 核 8G CI runner,Spring Boot 2.3,512 个用例):
| 方式 | 总耗时 | 需要 DB/Redis |
|---|---|---|
@SpringBootTest 全上下文 |
11m40s | 是 |
@WebMvcTest 切片 |
2m10s | 否 |
| 纯 Mockito 单测 | 40s | 否 |
差 17 倍。老项目一上来就 @SpringBootTest 是补测试最常见的性能陷阱,写着写着大家就不愿意跑了。
坑三:拿 H2 替代老 MySQL,SQL 方言会教你做人
// 生产 SQL 里这些写法,H2 全不认
// insert into t_order(...) values(...) on duplicate key update amount = values(amount)
// select group_concat(id) from t_order
ON DUPLICATE KEY UPDATE 在 H2 里直接 Syntax error,group_concat 在 H2 里叫 listagg。这类 DAO 层的测试别硬凑,用 Testcontainers 起同版本的 MySQL:
@Testcontainers
class OrderDaoTest {
// 镜像版本必须和线上一致:老项目是 5.7,用 mysql:8 会死在 utf8mb4_0900_ai_ci 这个排序规则上
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:5.7.36")
.withDatabaseName("order_db")
.withInitScript("schema.sql");
@Test
void updateAmount_shouldOverwrite() { /* ... */ }
}
代价是每个模块跑测试时多花 20~40 秒拉容器,CI 上要求 Docker 可用。所以判断标准很简单:测的是业务编排就用 mock,测的是 SQL 对不对就必须上真库,别拿 H2 自我安慰——H2 跑通的 SQL 上线照样能挂。
覆盖率别看全量,看增量
老项目全量覆盖率可能只有 12%,你给自己定个 60% 的目标,等于要写几千个用例,没人推得动。真正有用的是本次改动涉及的行有没有被测到。
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
mvn -q test jacoco:report
pip install diff-cover
# 只看相对主干新增/修改的行,覆盖率低于 70% 就让 CI 红
diff-cover target/site/jacoco/jacoco.xml \
--compare-branch=origin/master \
--fail-under=70
多模块项目要用 jacoco:report-aggregate 合并出一份 XML,否则 diff-cover 只看到单个模块的数字。起步门槛建议 60,稳定后再往 70 调——一上来设 80,第二天就有人加 @Ignore 绕过。
哪些地方不值得补测试
| 代码类型 | 建议 | 原因 |
|---|---|---|
| 纯 getter/setter、DTO | 不补 | 覆盖率工具算它"已覆盖",写了也没收益 |
| 只做转发的 Controller | 用 MockMvc 冒烟 | 单测断言不了参数绑定和序列化 |
| 硬编码 SQL 的 DAO | Testcontainers | mock Connection 断言不了 SQL 对不对 |
| 静态工具类里的纯函数 | 优先补 | 零依赖,几行代码就能锁住行为 |
| 定时任务、MQ 消费者 | 只测编排层 | 框架那一层不该你测 |
| 三个月内下线的模块 | 不补 | 投入直接归零 |
一个必须避开的错误断言写法
// ✗ 把整个对象序列化后比对字符串
assertEquals("{orderId:123, amount:100.00, discount:5.00, traceId:abc}", result.toString());
看着挺全,实际是个定时炸弹:别人加个字段、调个字段顺序、换掉 toString 实现,这个用例就红,而你要花十分钟才能确认"业务没坏,是快照对不上"。三次之后,团队里所有人都会条件反射地改断言,等于白写。
// ✓ 只断言业务上真正关心的值
assertEquals(new BigDecimal("90.25"), result.getPayAmount());
assertEquals(1, result.getAppliedRules().size());
// 对象比对用递归比较,跳过噪声字段
assertThat(result)
.usingRecursiveComparison()
.ignoringFields("traceId", "createTime", "updateTime")
.isEqualTo(expected);
推进节奏
不要开一个叫"补单元测试"的长期分支——它一定会腐化,也一定会和主干打架。正确做法是:每做一个业务需求,diff 里改到哪个方法,就顺手给哪个方法切接缝、补用例。半年后会发现改过的地方都有测试,没动过的地方保持原样,这才是可持续的。
下次你打开那个 780 行的类,先别写 @Test。找找里面哪一行 new 可以挪进一个 protected 方法,那才是第一步。