⭐ 推荐:社区规则条款 V1.0

Java 单元测试:JUnit 5 + Mockito + Testcontainers

东来东往
东来东往 正式会员正式会员认证极客认证极客
发布于 2026-10-09 12:38 ·3 浏览 ·2 回复

学完这篇,你能从零把 JUnit 5、Mockito、Testcontainers 三层测试工具装进一个 Java 项目,并分清「哪些逻辑该用 Mock 测、哪些必须上真容器」。

第一步:装依赖,先把版本管住

Maven 项目直接改 pom.xml。Testcontainers 有十几个模块,务必用 BOM 统一版本,否则 mysql 模块和核心模块版本错开会报 NoSuchMethodError:

<properties>
  <testcontainers.version>1.20.4</testcontainers.version>
  <mockito.version>5.14.2</mockito.version>
</properties>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers-bom</artifactId>
      <version>${testcontainers.version}</version>
      <type>pom</type><scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.3</version><scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>${mockito.version}</version><scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId><scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId><scope>test</scope>
  </dependency>
</dependencies>

Spring Boot 项目更省事:spring-boot-starter-test 已经带了 JUnit 5 和 Mockito,只补 Testcontainers 那两条即可。

第二步:确认 Surefire 版本(新手最常翻车的地方)

跑 mvn test,如果输出 Tests run: 0 却 BUILD SUCCESS,八成是 Surefire 太老,不认 JUnit 5 的引擎。显式锁定版本:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>3.5.2</version>
</plugin>

集成测试建议用 Failsafe 分开跑:*Test.java 归 Surefire(mvn test),*IT.java 归 Failsafe(mvn verify),这样日常开发不用每次都启 Docker。

第三步:写 JUnit 5 测试,先把纯逻辑测干净

JUnit 5 的入口是 org.junit.jupiter.api,别 import 成 JUnit 4 的 org.junit.Test,那个注解在 JUnit 5 下不生效:

@DisplayName("订单金额校验")
class OrderTest {

    @ParameterizedTest(name = "金额 {0} 应被拒绝")
    @ValueSource(ints = {0, -1, -100})
    void shouldRejectNonPositive(int amount) {
        assertThrows(IllegalArgumentException.class, () -> new Order(amount));
    }

    @Nested
    @DisplayName("已支付订单")
    class Paid {
        @Test
        void cannotRefundTwice() { /* ... */ }
    }
}

@ParameterizedTest + @Nested 能把边界值和场景分组写得非常紧凑,比一堆 test1、test2 可读得多。

第四步:Mockito 隔离外部依赖

单元测试的原则是「不碰网络、不碰数据库」,那些东西全部用 @Mock 顶掉:

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock PaymentGateway gateway;
    @Mock OrderRepository repo;
    @InjectMocks OrderService service;

    @Test
    void 支付成功应落库为已支付() {
        when(gateway.charge(any())).thenReturn(PaymentResult.ok("T123"));
        when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));

        Order order = service.pay(new OrderRequest("u1", 100));

        assertEquals(OrderStatus.PAID, order.getStatus());
        verify(gateway, times(1)).charge(any());
        verify(repo).save(order);
    }
}

注意:MockitoExtension 默认是 STRICT_STUBS 模式,stub 了但没被调用会直接抛 UnnecessaryStubbingException。这不是 bug,是在帮你清理废代码——真需要放宽时用 @MockitoSettings(strictness = Strictness.LENIENT),别一上来就全局关掉。

注意:@InjectMocks 会优先用构造器注入,其次是 setter,最后才反射写字段。如果类里既有构造器又有多参 setter,注入结果可能和你预期不一致,排查时先看构造器签名。

校验复杂参数用 ArgumentCaptor:

ArgumentCaptor<PaymentRequest> captor = ArgumentCaptor.forClass(PaymentRequest.class);
verify(gateway).charge(captor.capture());
assertEquals(100, captor.getValue().getAmount());

第五步:Testcontainers 起真数据库

涉及 SQL 方言、事务、唯一索引、JSON 字段的代码,Mock 是测不出问题的,得用真 MySQL:

@Testcontainers
@SpringBootTest
class OrderRepositoryIT {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
            .withDatabaseName("demo")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }
}

@Container 必须写在 static 字段上,容器才会在整个测试类里只启动一次;写成实例字段会导致每个测试方法重启一次容器,一个类跑两分钟。

多个测试类共用时,抽一个基类做单例容器:

public abstract class AbstractIT {
    static final MySQLContainer<?> MYSQL =
            new MySQLContainer<>("mysql:8.0").withReuse(true);
    static { MYSQL.start(); }
}

注意:withReuse(true) 还要在 ~/.testcontainers.properties 里加一行 testcontainers.reuse.enable=true 才生效。CI 环境别开 reuse,否则容器状态会跨构建污染。

第六步:按层分配测试类型

经验做法就三条:

  • 纯计算、分支判断、异常路径 → JUnit 5 + Mockito,毫秒级,每次提交都跑。
  • 涉及 SQL、事务、序列化 → Testcontainers,mvn verify 阶段跑。
  • 涉及 Controller 到 DB 的全链路 → @SpringBootTest + Testcontainers,数量控制在个位数。

注意:Testcontainers 依赖本机 Docker。Linux 上当前用户必须在 docker 组里(sudo usermod -aG docker $USER 后重新登录),Windows/macOS 装 Docker Desktop 即可。容器结束由 Ryuk 容器负责清理,如果公司防火墙拦了 Docker socket,会出现「构建结束了容器还在跑」的现象。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-762.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
CLARA轻量论坛系统

全部回复 2

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 1楼 2026-10-09 12:45

Surefire 版本就是你说的那个坑——JUnit 5 必须配 maven-surefire-plugin 3.0.0+(最低 2.22.0),而 Spring Boot 2.2 之前的父 POM 默认还停在 2.12.4,所以才会 Tests run: 0 却 BUILD SUCCESS。

修法很简单,在 build/plugins 里显式覆盖:surefire 升到 3.2.x;如果需要跑 Testcontainers 这类集成测试,建议再引 maven-failsafe-plugin,把 *IT 结尾的类交给 mvn verify 跑(failsafe 绑定 integration-test/verify 阶段),这样 mvn test 只跑快测、CI 里 mvn verify 才起容器,本地开发体验会好很多。

Mockito 5 默认已是 inline mock maker,final 类和 static 方法都能 mock,不用再单独引 mockito-inline 了,注意 JDK 版本别低于 11。

Testcontainers 两点提醒:一是必须有 Docker daemon(CI 上别用精简镜像,得带 docker);二是容器生命周期,@Container 默认每类启停一次,DB 容器太重,建议写个抽象基类用 static 单例 @Container,或者开 reuse 模式,否则一个模块跑下来能起十几个 MySQL。容器端口要用 @DynamicPropertySource 动态塞进 spring.datasource.url,别硬编码 3306。

边界划分一句话:纯逻辑、外部 HTTP 服务用 Mock;涉及 SQL、事务、JPA 映射、Liquibase 迁移的必须上真容器——H2 和 MySQL 的方言差异能坑到你怀疑人生。

延伸一个高频坑:@DataJpaTest 默认会把数据源替换成内嵌 H2,接了 Testcontainers 后一定要加 @AutoConfigureTestDatabase(replace = NONE),否则你以为在测真库,其实还在测 H2。

小易先生
小易先生 见习用户见习用户 #593 2楼 2026-10-09 12:52
玄墨染:Surefire 版本就是你说的那个坑——JUnit 5 必须配 maven-surefire-plugin 3.0.0+(最低 2.22.0),而 Sprin…

补充得很完整,我只在上面三点各加一个「实际踩过」的细节:容器单例最好别挂在 @Testcontainers 上,CI 要处理 Ryuk,Spring Boot 3.1+ 可以彻底省掉 @DynamicPropertySource。

① 单例容器用静态块,不要注解。 写成抽象基类、字段 static final、在 static { DB.start(); } 里启动即可,别加 @Testcontainers —— 那个扩展会在每个测试类结束时回调关闭它认定的容器,容易把共享实例提前停掉。静态块方式由 JVM 退出时 Ryuk 兜底回收,全模块只起一个库。至于 reuse 模式,需要在 ~/.testcontainers.properties 显式写 testcontainers.reuse.enable=true,而且对 @Container 注解声明的容器基本不生效(文档里主要面向手动 new 的容器),CI 每次都换新 daemon 也享受不到,所以静态单例才是正解。

② Ryuk 在 CI 上经常是第一个挂的。 精简镜像、rootless Docker、Docker-in-Docker 场景下 Ryuk 挂不上 socket,报 Could not find a valid Docker environment。对策:TESTCONTAINERS_RYUK_DISABLED=true 交给 CI 自己清容器;远程/嵌套 Docker 再补 TESTCONTAINERS_HOST_OVERRIDE 指向宿主机可达地址,DOCKER_HOST 记得设。

**③ Boot 3.1+ 用 @ServiceConnection。