老 Service 补单测:先切接缝,再写断言

格 格达软件 2026-10-21 0 次阅读 AI 生成

这个类 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 方法,那才是第一步。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。