Java 基础体系 · 第 19/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 测试体系:JUnit 5、AssertJ、Mockito、Testcontainers 和并发测试

测试不是“调用方法后检查一个返回值”这么简单。一个可维护的 Java 测试体系至少要回答以下问题:

  1. 被测行为的输入、输出和失败契约是什么?
  2. 测试本身如何被发现、隔离、执行和报告?
  3. 如何表达复杂断言,而不把失败信息写成难以诊断的字符串比较?
  4. 如何替换外部依赖,并验证交互是否符合协议?
  5. 如何使用真实数据库、消息代理或其他基础设施验证集成行为?
  6. 如何测试并发程序中的可见性、原子性、顺序和资源关闭?
  7. 一个测试失败时,如何区分生产代码错误、测试替身错误、环境错误和调度偶然性?

本文以 Java 25 为编译和运行基线,使用 JUnit 5、AssertJ、Mockito、Testcontainers,并从 Java 内存模型、异常契约和资源生命周期出发构建一套完整的测试方法。


一、先定义测试对象:行为、状态与协议

测试的基本单位不是“类”,而是一个可观察的行为。

对于一个操作:

f:(S,I)(S,O,E)f: (S, I) \rightarrow (S', O, E)

可以把它理解为:

  • SS:调用前的系统状态;
  • II:输入;
  • SS':调用后的系统状态;
  • OO:正常输出;
  • EE:异常或其他失败结果。

例如,一个扣减库存的操作可以描述为:

  • 输入:商品编号 A、数量 2
  • 前置状态:库存为 5
  • 正常结果:返回成功;
  • 后置状态:库存为 3
  • 失败条件:库存不足时抛出 InsufficientStockException
  • 交互协议:成功后持久化一次,库存不足时不执行持久化。

测试应当同时验证三个层次:

输入与前置状态
        │
        ▼
   被测行为
        │
 ┌──────┴──────┐
 ▼             ▼
正常输出       异常结果
        │
        ▼
后置状态与外部交互

只验证返回值可能漏掉状态错误;只验证 Mockito 的调用次数,又可能没有验证真正的业务结果。完整测试通常需要组合:

  • 状态断言:结果、数据库记录、对象状态;
  • 异常断言:异常类型、消息、错误链;
  • 交互断言:依赖是否被调用、调用参数和顺序;
  • 并发断言:所有执行结果满足不变量;
  • 基础设施断言:真实数据库约束、事务和 SQL 行为成立。

二、JUnit 5 的组成与执行模型

2.1 JUnit 5 不是单个库

JUnit 5 的平台由几个职责不同的组件组成:

  • JUnit Platform:测试发现和执行的基础平台;
  • JUnit Jupiter:JUnit 5 的编程模型,包括 @Test、生命周期、参数化测试和扩展模型;
  • JUnit Vintage:在平台上运行 JUnit 3/4 测试的引擎。

通常 Maven 项目使用 Jupiter API 和 Engine:

<properties>
    <maven.compiler.release>25</maven.compiler.release>
    <junit.version>5.12.2</junit.version>
    <assertj.version>3.27.3</assertj.version>
    <mockito.version>5.17.0</mockito.version>
    <testcontainers.version>1.20.6</testcontainers.version>
</properties>

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

    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>${assertj.version}</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>
        <version>${testcontainers.version}</version>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <version>${testcontainers.version}</version>
        <scope>test</scope>
    </dependency>
</dependencies>

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

这里的版本只是一个可工作的示例组合。实际项目应使用经过验证的版本,并通过依赖管理统一升级;不能因为 Java 25 已发布,就假定所有测试库都自动支持该版本。

执行:

mvn test

Maven Surefire 会发现测试类并调用 JUnit Platform。典型输出包含:

Tests run: 3, Failures: 0, Errors: 0, Skipped: 0

“测试通过”只表示测试断言通过,不表示测试覆盖了所有行为,也不表示测试替身和真实基础设施之间不存在差异。


2.2 测试方法、实例与生命周期

一个最小的 Jupiter 测试如下:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class CalculatorTest {

    @Test
    void addsTwoNumbers() {
        int result = 2 + 3;

        assertEquals(5, result);
    }
}

JUnit 默认对每个 @Test 方法创建一个新的测试类实例。这意味着:

class IsolatedTest {

    private int counter = 0;

    @Test
    void first() {
        counter++;
        assertEquals(1, counter);
    }

    @Test
    void second() {
        counter++;
        assertEquals(1, counter);
    }
}

两个测试可以分别通过,因为实例字段不会跨测试复用。这个默认行为有助于隔离测试。

如果使用:

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SharedInstanceTest {
    // 整个测试类共享一个实例
}

则实例字段会跨方法共享。这样可以让非静态的 @BeforeAll@AfterAll 工作,但也引入状态泄漏风险:

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class LeakyTest {

    private final List<String> values = new ArrayList<>();

    @Test
    void first() {
        values.add("x");
    }

    @Test
    void second() {
        // 如果 first 先执行,这里可能看到 x;
        // JUnit 不保证测试方法顺序,测试因此不稳定。
        assertThat(values).isEmpty();
    }
}

除非共享状态本身是测试目的,否则应优先保留默认的 PER_METHOD 生命周期。

常用生命周期注解的顺序是:

@BeforeAll
  ├─ @BeforeEach → @Test → @AfterEach
  ├─ @BeforeEach → @Test → @AfterEach
  └─ ...
@AfterAll

它们的职责不同:

  • @BeforeAll:建立整个测试类共享的昂贵资源;
  • @BeforeEach:建立单个测试的初始状态;
  • @AfterEach:释放单个测试资源;
  • @AfterAll:释放整个测试类共享的资源。

清理逻辑必须放在可靠的生命周期中。若资源关闭本身可能抛出异常,应保留原始失败并处理关闭异常,而不是让清理异常覆盖真正的断言失败。


2.3 标记测试意图:名称、标签和嵌套

测试名称应描述行为,而不是实现方法名:

@Test
void rejectsOrderWhenRequestedQuantityExceedsStock() {
}

标签用于筛选测试:

@Tag("unit")
class OrderServiceTest {
}

@Tag("integration")
class OrderRepositoryIT {
}

执行单元测试:

mvn test -Dgroups=unit

执行命令的具体参数还取决于 Surefire 配置;如果使用 JUnit Platform 原生配置,也可以通过 junit-platform.properties 或 Maven 插件配置筛选。不要仅仅添加 @Tag 就假设构建工具一定会按标签分组。

@Nested 适合把前置状态组织成层次:

class DiscountTest {

    @Nested
    class WhenMemberIsActive {

        @Test
        void appliesMemberDiscount() {
        }
    }

    @Nested
    class WhenMemberIsExpired {

        @Test
        void doesNotApplyMemberDiscount() {
        }
    }
}

嵌套类的生命周期和外部实例有关;如果外部测试类使用默认生命周期,嵌套结构仍应避免依赖可变共享字段。


三、JUnit 5 断言与 AssertJ

3.1 断言的职责是表达可观察契约

JUnit Jupiter 自带断言适合基础判断:

import static org.junit.jupiter.api.Assertions.*;

@Test
void basicAssertions() {
    assertEquals(42, 40 + 2);
    assertTrue("java".startsWith("j"));
    assertFalse(List.of().contains("x"));
    assertNull(null);
}

失败消息应该延迟计算,避免断言通过时不必要地构造字符串:

assertEquals(
        expected,
        actual,
        () -> "expected order total for input=" + input
);

assertAll 用于收集多个相关断言:

assertAll(
        () -> assertEquals("A-100", order.id()),
        () -> assertEquals(OrderStatus.PAID, order.status()),
        () -> assertEquals(new BigDecimal("19.99"), order.total())
);

如果第一个断言失败,普通连续断言会立即停止;assertAll 会执行组内其他断言,然后汇总失败结果。它适合检查一个对象的多个独立属性,但不适合隐藏前置条件失败。例如对象创建失败后继续读取其字段,反而会产生误导性异常。


3.2 异常测试必须验证完整契约

异常测试不仅要验证“抛了异常”,还要验证异常类型和必要的语义:

@Test
void rejectsNegativeQuantity() {
    IllegalArgumentException exception = assertThrows(
            IllegalArgumentException.class,
            () -> new Quantity(-1)
    );

    assertEquals("quantity must be positive", exception.getMessage());
}

assertThrows 接受异常的子类型。例如断言 RuntimeException.class,则抛出 IllegalArgumentException 也会通过。如果 API 契约要求精确类型,应使用:

assertThrowsExactly(IllegalArgumentException.class, action);

错误链也属于异常契约的一部分。假设基础设施异常被转换为领域异常:

class OrderLoadException extends RuntimeException {
    OrderLoadException(String message, Throwable cause) {
        super(message, cause);
    }
}

测试应验证原因未丢失:

@Test
void preservesInfrastructureCause() {
    RepositoryException cause = new RepositoryException("database unavailable");

    OrderLoadException exception = assertThrows(
            OrderLoadException.class,
            () -> service.loadWithFailure(cause)
    );

    assertSame(cause, exception.getCause());
}

如果生产代码写成:

throw new OrderLoadException("load failed", null);

虽然测试可能只验证了外层类型,但诊断信息已被破坏。异常转换应保留 cause,除非出于安全策略明确不能暴露底层细节。

受检异常还会影响 Mockito 的配置:thenThrow 不能随意让一个不声明受检异常的方法抛出不允许的受检异常。测试替身必须遵守被替换方法的 Java 类型契约。


3.3 AssertJ:把断言写成领域语言

AssertJ 的核心优势不是“断言更多”,而是:

  • 链式表达;
  • 更详细的失败信息;
  • 集合、对象、异常、时间等专用断言;
  • 可读的组合条件。
import static org.assertj.core.api.Assertions.assertThat;

@Test
void describesOrderResult() {
    OrderResult result = new OrderResult(
            "A-100",
            OrderStatus.PAID,
            new BigDecimal("19.99"),
            List.of("book", "pen")
    );

    assertThat(result)
            .extracting(OrderResult::id, OrderResult::status)
            .containsExactly("A-100", OrderStatus.PAID);

    assertThat(result.total())
            .isEqualByComparingTo("19.99");

    assertThat(result.items())
            .containsExactly("book", "pen")
            .doesNotContain("laptop");
}

BigDecimal 是常见边界:

new BigDecimal("1.0").equals(new BigDecimal("1.00")) // false

因为 equals 同时比较数值和 scale;而 compareTo 只比较数值。因此金额断言应使用:

assertThat(actual).isEqualByComparingTo("1.00");

这比把金额转换成 double 再比较更可靠。

集合断言需要区分顺序语义:

assertThat(values).containsExactly("a", "b", "c"); // 顺序和重复次数都重要
assertThat(values).containsExactlyInAnyOrder("a", "b", "c"); // 顺序不重要
assertThat(values).contains("a", "b"); // 只要求包含

如果业务协议要求按创建顺序返回结果,使用 containsExactly;如果数据库查询未承诺顺序,就不应在测试中假造顺序契约。


3.4 AssertJ 的异常断言

AssertJ 可以在一个表达式中描述异常:

import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Test
void reportsInvalidOrder() {
    assertThatThrownBy(() -> service.submit("missing"))
            .isInstanceOf(OrderNotFoundException.class)
            .hasMessage("order not found: missing")
            .hasNoCause();
}

验证错误链:

assertThatThrownBy(() -> service.loadWithFailure(cause))
        .isInstanceOf(OrderLoadException.class)
        .hasCauseReference(cause)
        .hasMessageStartingWith("load failed");

不要无条件精确断言完整异常消息。消息常常是面向人的诊断文本,若它不是 API 契约的一部分,测试可以只断言关键字段、异常类型和 cause。否则一次合法的措辞修改就会造成脆弱测试。


四、参数化测试、动态测试与扩展

4.1 参数化测试验证输入空间

当多个输入遵循同一规则时,参数化测试比复制多个方法更准确:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.assertj.core.api.Assertions.assertThat;

class TaxCalculatorTest {

    @ParameterizedTest
    @CsvSource({
            "0,      0",
            "100,    6",
            "1000,   60"
    })
    void calculatesTax(String price, String expectedTax) {
        assertThat(TaxCalculator.tax(price))
                .isEqualByComparingTo(expectedTax);
    }
}

@CsvSource 的每一列先按字符串读取,再转换为参数类型。参数顺序必须与测试方法参数顺序一致。

复杂对象可用 @MethodSource

@ParameterizedTest
@MethodSource("invalidQuantities")
void rejectsInvalidQuantity(int quantity) {
    assertThatThrownBy(() -> new Quantity(quantity))
            .isInstanceOf(IllegalArgumentException.class);
}

static Stream<Integer> invalidQuantities() {
    return Stream.of(-2, -1, 0);
}

参数化测试的价值在于显式展示边界集合:

  • 最小合法值;
  • 最大合法值;
  • 空值;
  • 空集合;
  • 负数;
  • 溢出值;
  • 代表性非法值。

它不能证明无限输入空间中的所有情况。要覆盖所有情况,需要数学证明、性质测试或专门的测试生成工具。


4.2 动态测试适合运行时生成测试节点

动态测试使用 @TestFactory

@TestFactory
Stream<DynamicTest> parsesAllSupportedFormats() {
    return Stream.of(
            Arguments.of("2025-01-01", LocalDate.of(2025, 1, 1)),
            Arguments.of("2025-12-31", LocalDate.of(2025, 12, 31))
    ).map(arguments -> {
        Object[] values = arguments.get();
        String input = (String) values[0];
        LocalDate expected = (LocalDate) values[1];

        return DynamicTest.dynamicTest(
                "parses " + input,
                () -> assertThat(Parser.parse(input)).isEqualTo(expected)
        );
    });
}

动态测试的测试节点直到工厂方法运行时才生成。它适合:

  • 从配置或资源文件读取案例;
  • 对协议版本列表生成测试;
  • 对注册表中的所有实现进行一致性验证。

但测试发现工具在执行前无法知道所有动态节点,因此 IDE 和报告系统对它们的展示可能不如静态 @Test 清晰。固定、少量、重要的案例优先使用静态参数化测试。


4.3 扩展模型不是全局魔法

JUnit 5 的扩展模型允许在测试生命周期中插入逻辑,例如:

  • 参数解析;
  • 测试实例后处理;
  • BeforeEach/AfterEach 回调;
  • 异常处理;
  • 条件执行。

JUnit 注解本身就是扩展机制的使用者。Mockito 的 @ExtendWith(MockitoExtension.class) 也是如此。

扩展的生命周期必须明确。如果扩展创建了数据库连接、线程池或临时目录,就必须有对应的清理路径。否则测试运行几百次后可能耗尽连接、文件句柄或线程。


五、Mockito:替换依赖,但不能替换业务事实

5.1 Mock、Stub、Spy 的区别

Mockito 创建的是测试替身:

  • Mock:通常用于验证交互;
  • Stub:预先配置返回值或异常;
  • Spy:包装真实对象,部分调用真实实现;
  • MockedStatic / MockedConstruction:替换静态方法或构造过程,应谨慎使用。

例如:

interface OrderRepository {
    Optional<Order> findById(String id);
    void save(Order order);
}

interface PaymentGateway {
    PaymentResult charge(String orderId, BigDecimal amount);
}

被测服务:

final class OrderService {

    private final OrderRepository repository;
    private final PaymentGateway paymentGateway;

    OrderService(OrderRepository repository, PaymentGateway paymentGateway) {
        this.repository = repository;
        this.paymentGateway = paymentGateway;
    }

    void pay(String orderId) {
        Order order = repository.findById(orderId)
                .orElseThrow(() -> new OrderNotFoundException(orderId));

        PaymentResult result = paymentGateway.charge(order.id(), order.total());

        if (!result.success()) {
            throw new PaymentFailedException(orderId);
        }

        order.markPaid();
        repository.save(order);
    }
}

测试:

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    OrderRepository repository;

    @Mock
    PaymentGateway paymentGateway;

    @InjectMocks
    OrderService service;

    @Test
    void paysOrderAndPersistsPaidState() {
        Order order = new Order("A-100", new BigDecimal("19.99"));

        when(repository.findById("A-100"))
                .thenReturn(Optional.of(order));
        when(paymentGateway.charge("A-100", new BigDecimal("19.99")))
                .thenReturn(PaymentResult.success());

        service.pay("A-100");

        assertThat(order.status()).isEqualTo(OrderStatus.PAID);
        verify(paymentGateway).charge("A-100", new BigDecimal("19.99"));
        verify(repository).save(order);
        verifyNoMoreInteractions(repository, paymentGateway);
    }
}

这里有三类断言:

  1. order.status():业务状态;
  2. verify(paymentGateway):支付协议;
  3. verify(repository):持久化协议。

只验证 verify(repository).save(order) 不足以证明订单真的被标记为已支付。


5.2 Stub 的匹配规则和参数捕获

Mockito 的 Stub 必须匹配实际调用:

when(repository.findById("A-100"))
        .thenReturn(Optional.of(order));

如果实际调用是 "A-101",Mockito 会返回默认值。对 Optional 而言,默认值通常是 Optional.empty(),最终可能导致错误的 OrderNotFoundException

当参数本身需要验证时,可以用 ArgumentCaptor

@Test
void savesChangedOrder() {
    Order order = new Order("A-100", new BigDecimal("19.99"));

    when(repository.findById(order.id())).thenReturn(Optional.of(order));
    when(paymentGateway.charge(anyString(), any(BigDecimal.class)))
            .thenReturn(PaymentResult.success());

    service.pay(order.id());

    ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
    verify(repository).save(captor.capture());

    Order saved = captor.getValue();
    assertThat(saved.status()).isEqualTo(OrderStatus.PAID);
}

如果只关心参数满足某个条件,argThat 通常比捕获后再断言更直接:

verify(repository).save(argThat(saved ->
        saved.id().equals("A-100")
                && saved.status() == OrderStatus.PAID
));

不要在所有调用上使用 any()。过宽匹配会让错误参数也通过,测试失去保护作用。


5.3 交互验证的边界

verify 验证的是测试替身观察到的调用,不是外部系统真实发生了什么。例如:

verify(paymentGateway).charge("A-100", amount);

只能说明服务向 Mockito mock 发起了调用,不能证明:

  • HTTP 请求格式正确;
  • TLS、超时和重试配置正确;
  • 支付供应商实际接受了请求;
  • JSON 序列化符合对方协议。

这些需要 HTTP 级别的契约测试或集成测试。

交互测试应围绕外部协议,而不是内部实现细节。比如验证“支付失败时不保存订单”是稳定契约:

@Test
void doesNotSaveWhenPaymentFails() {
    Order order = new Order("A-100", new BigDecimal("19.99"));

    when(repository.findById(order.id())).thenReturn(Optional.of(order));
    when(paymentGateway.charge(order.id(), order.total()))
            .thenReturn(PaymentResult.failure());

    assertThatThrownBy(() -> service.pay(order.id()))
            .isInstanceOf(PaymentFailedException.class);

    assertThat(order.status()).isEqualTo(OrderStatus.PENDING);
    verify(repository, never()).save(any());
}

而验证“内部调用了某个私有辅助方法”通常不是有效测试,因为私有实现不是外部协议。


5.4 @InjectMocks 的真实含义和陷阱

@InjectMocks 会尝试按构造器、Setter 或字段注入 mock。它方便,但不应掩盖依赖设计问题:

@InjectMocks
OrderService service;

如果构造器参数发生变化,测试可能自动注入部分 mock,剩余依赖为 null,直到执行路径触发 NullPointerException。显式构造更容易发现依赖变化:

@BeforeEach
void setUp() {
    service = new OrderService(repository, paymentGateway);
}

对于生产代码,优先使用构造器注入;对于测试代码,构造器注入也能让测试的依赖图可见。


5.5 Spy 和静态 Mock 的风险

Spy 调用真实方法:

List<String> list = spy(new ArrayList<>());

它可能执行真实副作用、访问时间、文件或网络。when(spy.method()) 甚至会先执行真实方法;必要时使用:

doReturn(value).when(spy).method();

静态 Mock:

try (MockedStatic<Clock> mocked = mockStatic(Clock.class)) {
    // ...
}

必须使用 try-with-resources 限定作用域。静态替换是线程局部或作用域相关的实现机制,不能把它当作跨测试全局配置。大量依赖静态 Mock 往往说明代码缺少可注入的时钟、随机数源或外部客户端。


六、Testcontainers:在真实基础设施上验证集成行为

6.1 为什么 Mock 数据库不等于测试数据库

Mock Repository 可以验证服务是否调用了 save,但不能验证:

  • SQL 是否语法正确;
  • 列名映射是否正确;
  • 唯一约束是否有效;
  • 事务提交和回滚是否正确;
  • PostgreSQL 与本地 H2 的类型语义是否一致;
  • 索引或隔离级别是否改变结果。

Testcontainers 使用 Docker 容器提供真实依赖。测试数据流通常是:

JUnit 生命周期
    │
    ▼
启动 Docker 容器
    │
    ▼
等待服务就绪
    │
    ▼
获取动态连接信息
    │
    ▼
迁移表结构并执行测试
    │
    ▼
清理容器与临时资源

前置条件:

docker version

必须能够连接到 Docker daemon。若容器启动失败,应先检查 Docker 服务、镜像拉取权限、网络和端口资源,而不是把环境故障误判为业务测试失败。


6.2 PostgreSQL 的端到端测试

示例:

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.sql.*;

import static org.assertj.core.api.Assertions.assertThat;

@Testcontainers
class OrderDatabaseTest {

    @Container
    static final PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine")
                    .withDatabaseName("orders")
                    .withUsername("test")
                    .withPassword("test");

    @Test
    void insertsAndReadsOrder() throws Exception {
        try (Connection connection = DriverManager.getConnection(
                postgres.getJdbcUrl(),
                postgres.getUsername(),
                postgres.getPassword())) {

            try (Statement statement = connection.createStatement()) {
                statement.execute("""
                        create table orders (
                            id varchar(50) primary key,
                            total numeric(12, 2) not null,
                            status varchar(20) not null
                        )
                        """);
            }

            try (PreparedStatement insert = connection.prepareStatement("""
                    insert into orders(id, total, status)
                    values (?, ?, ?)
                    """)) {
                insert.setString(1, "A-100");
                insert.setBigDecimal(2, new java.math.BigDecimal("19.99"));
                insert.setString(3, "PAID");
                assertThat(insert.executeUpdate()).isEqualTo(1);
            }

            try (PreparedStatement query = connection.prepareStatement("""
                    select id, total, status
                    from orders
                    where id = ?
                    """)) {
                query.setString(1, "A-100");

                try (ResultSet resultSet = query.executeQuery()) {
                    assertThat(resultSet.next()).isTrue();
                    assertThat(resultSet.getString("id")).isEqualTo("A-100");
                    assertThat(resultSet.getBigDecimal("total"))
                            .isEqualByComparingTo("19.99");
                    assertThat(resultSet.getString("status")).isEqualTo("PAID");
                }
            }
        }
    }
}

@Testcontainers 扩展负责识别 @Container 字段,并在测试生命周期中启动和停止容器。

静态容器:

@Container
static final PostgreSQLContainer<?> postgres = ...;

通常在测试类中的多个测试之间复用同一个容器。它减少启动次数,但会共享数据库状态。若每个测试都创建同名表或留下数据,后续测试会失败。

实例容器则每个测试实例启动一次,隔离性更强但速度更慢:

@Container
PostgreSQLContainer<?> postgres = ...;

应根据状态污染风险选择,而不是只追求启动速度。


6.3 “容器启动了”不等于“应用可用”

Testcontainers 通常会等待容器满足就绪条件,但就绪条件不等于业务健康:

  • 进程已启动,不代表迁移已完成;
  • 端口可连接,不代表目标表已创建;
  • 数据库接受连接,不代表用户权限和 schema 正确;
  • 消息代理启动,不代表主题或队列已存在。

因此集成测试需要明确初始化顺序:

  1. 启动容器;
  2. 等待服务就绪;
  3. 创建 schema 或运行 Flyway/Liquibase 迁移;
  4. 插入测试所需的最小数据;
  5. 执行被测操作;
  6. 检查状态和约束;
  7. 清理数据或销毁容器。

对于 Spring Boot 测试,容器的 JDBC URL 必须通过动态配置传给应用,而不能在测试代码中拿到 URL、却让 Spring 仍然连接本地数据库。Spring Boot 常见方式包括 @DynamicPropertySource 或框架提供的服务连接机制;具体能力取决于 Spring Boot 版本。


6.4 集成测试的隔离策略

数据库测试可以采用几种隔离方式:

  • 每个测试使用唯一 schema;
  • 每个测试包围在事务中,并在结束时回滚;
  • 每个测试删除数据;
  • 每个测试启动独立容器。

它们的语义不同。

事务回滚不能覆盖所有情况:

  • 被测代码可能开启独立事务;
  • 异步线程不共享测试线程事务;
  • 数据库序列、自增值可能不会回退;
  • 某些 DDL 会隐式提交;
  • 外部消息发送不受数据库回滚控制。

因此“测试结束自动回滚”不是普遍隔离保证,必须确认事务边界和异步模型。


七、异常、资源关闭与测试生命周期

Java 的 try-with-resources 会在离开代码块时按声明的逆序关闭资源:

try (Connection connection = dataSource.getConnection();
     PreparedStatement statement = connection.prepareStatement(sql)) {
    // 使用 statement
}

关闭顺序是:

statement.close()
connection.close()

如果主体抛出异常,关闭异常通常会被添加为 suppressed exception:

try (Resource resource = new Resource()) {
    throw new IllegalStateException("primary failure");
}

测试可以检查错误链:

@Test
void reportsSuppressedCloseFailure() {
    Exception exception = assertThrows(
            Exception.class,
            () -> useResourceThatFailsOnClose()
    );

    assertThat(exception.getSuppressed())
            .anySatisfy(suppressed ->
                    assertThat(suppressed)
                            .isInstanceOf(CloseException.class));
}

这里要区分:

  • getCause():异常的主要原因链;
  • getSuppressed():通常由资源关闭等附加失败产生的异常。

测试资源关闭时不能只断言主异常类型,否则可能漏掉资源泄漏或关闭错误。

在 JUnit 生命周期中,外部资源也应遵守同样原则:

Path tempFile;

@BeforeEach
void createFile() throws IOException {
    tempFile = Files.createTempFile("orders-", ".tmp");
}

@AfterEach
void deleteFile() throws IOException {
    Files.deleteIfExists(tempFile);
}

如果测试在 @BeforeEach 就失败,@AfterEach 是否执行、清理哪些字段,取决于测试框架生命周期和代码状态。清理代码应允许资源尚未创建:

@AfterEach
void cleanup() throws IOException {
    if (tempFile != null) {
        Files.deleteIfExists(tempFile);
    }
}

对于复杂资源,优先让资源本身实现 AutoCloseable,并在测试中使用明确的 try 或专门扩展管理生命周期。


八、并发测试:先理解 Java 内存模型,再写测试

8.1 并发错误通常不是“线程运行顺序错了”

以下代码不是线程安全的:

final class UnsafeCounter {
    private int value;

    void increment() {
        value++;
    }

    int value() {
        return value;
    }
}

value++ 实际包含至少三个步骤:

读取 value
计算 value + 1
写回 value

两个线程可能产生如下交错:

初始 value = 0

线程 A:读取 0
线程 B:读取 0
线程 A:写入 1
线程 B:写入 1

最终 value = 1,而不是 2

这叫丢失更新。问题不只是执行顺序,还包括 Java 内存模型中的可见性和 happens-before 关系。

常见建立 happens-before 的操作包括:

  • 对同一监视器的解锁先行于后续加锁;
  • volatile 变量的写先行于后续读;
  • Thread.start() 先行于新线程中的动作;
  • 线程中的动作先行于另一个线程成功从 join() 返回;
  • ExecutorService 提交任务前的动作先行于任务执行;
  • Future.get() 返回前,任务中的动作先行于调用方继续执行。

如果两个线程之间没有足够的同步关系,就不能仅凭“通常能读到”推断可见性。


8.2 正确实现与不变量

使用 AtomicInteger

final class AtomicCounter {
    private final AtomicInteger value = new AtomicInteger();

    void increment() {
        value.incrementAndGet();
    }

    int value() {
        return value.get();
    }
}

或者使用锁:

final class LockedCounter {
    private int value;

    synchronized void increment() {
        value++;
    }

    synchronized int value() {
        return value;
    }
}

并发测试的关键不是检查某一次运行的中间值,而是检查不变量:

最终值=初始值+线程数×每线程操作数最终值 = 初始值 + 线程数 \times 每线程操作数

测试:

@Test
void counterPreservesAllIncrements() throws Exception {
    int threadCount = 8;
    int incrementsPerThread = 10_000;

    AtomicCounter counter = new AtomicCounter();

    try (ExecutorService executor = Executors.newFixedThreadPool(threadCount)) {
        List<Future<?>> futures = new ArrayList<>();

        for (int i = 0; i < threadCount; i++) {
            futures.add(executor.submit(() -> {
                for (int j = 0; j < incrementsPerThread; j++) {
                    counter.increment();
                }
            }));
        }

        for (Future<?> future : futures) {
            future.get();
        }
    }

    assertThat(counter.value())
            .isEqualTo(threadCount * incrementsPerThread);
}

这里 Future.get() 有两个作用:

  1. 等待任务结束;
  2. 重新抛出任务内的异常。

如果不调用 get(),工作线程中的异常可能不会让测试失败,测试只会在主线程读取结果时继续执行。

Java 25 中 ExecutorService 可用于 try-with-resources;关闭线程池并不等价于验证所有任务已成功,因此仍需要保留并检查 Future


8.3 用屏障制造指定的交错

并发测试不能依赖线程“碰巧同时执行”。CyclicBarrierCountDownLatch 可以建立确定的测试阶段。

假设一个库存扣减操作必须保证库存不为负:

final class Inventory {
    private int stock;

    Inventory(int stock) {
        this.stock = stock;
    }

    void reserve(int quantity) {
        if (stock < quantity) {
            throw new IllegalStateException("insufficient stock");
        }
        stock -= quantity;
    }

    int stock() {
        return stock;
    }
}

两个线程同时预订最后一个库存时,可以使用屏障让它们尽可能同时进入操作:

@Test
void concurrentReservationsMustNotMakeStockNegative() throws Exception {
    Inventory inventory = new Inventory(1);
    CyclicBarrier barrier = new CyclicBarrier(2);

    try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
        Callable<Boolean> reserve = () -> {
            barrier.await();
            try {
                inventory.reserve(1);
                return true;
            } catch (IllegalStateException e) {
                return false;
            }
        };

        Future<Boolean> first = executor.submit(reserve);
        Future<Boolean> second = executor.submit(reserve);

        boolean firstSucceeded = first.get();
        boolean secondSucceeded = second.get();

        assertThat(firstSucceeded ^ secondSucceeded)
                .as("exactly one reservation should succeed")
                .isTrue();

        assertThat(inventory.stock()).isZero();
    }
}

对于当前 Inventory,这个测试可能偶尔失败,因为两个线程都可能通过 stock < quantity 检查,然后都写回 0。虽然最终库存没有负数,但成功次数为两个,业务不变量已被破坏。

修复:

final class SynchronizedInventory {
    private int stock;

    SynchronizedInventory(int stock) {
        this.stock = stock;
    }

    synchronized void reserve(int quantity) {
        if (stock < quantity) {
            throw new IllegalStateException("insufficient stock");
        }
        stock -= quantity;
    }

    synchronized int stock() {
        return stock;
    }
}

把检查和扣减放进同一临界区,保证:

检查(stockq)扣减(stock:=stockq)检查(stock \ge q) \land 扣减(stock := stock-q)

作为一个不可分割的操作执行。仅仅给 stock()synchronized 不能修复问题,因为竞态发生在 reserve 内部的“检查—修改”之间。


8.4 Latch 的方向与超时

CountDownLatch 适合“一次性打开闸门”:

@Test
void workersStartOnlyAfterRelease() throws Exception {
    int workers = 3;
    CountDownLatch ready = new CountDownLatch(workers);
    CountDownLatch start = new CountDownLatch(1);
    CountDownLatch finished = new CountDownLatch(workers);

    try (ExecutorService executor = Executors.newFixedThreadPool(workers)) {
        for (int i = 0; i < workers; i++) {
            executor.submit(() -> {
                ready.countDown();

                try {
                    start.await();
                    // 执行并发操作
                } finally {
                    finished.countDown();
                }
            });
        }

        assertThat(ready.await(2, TimeUnit.SECONDS)).isTrue();

        start.countDown();

        assertThat(finished.await(2, TimeUnit.SECONDS)).isTrue();
    }
}

每个等待都必须有超时。没有超时的 await() 会让一个生产代码死锁变成永不结束的 CI 任务。

超时失败后还要收集诊断信息:

  • 线程 dump;
  • 任务是否完成;
  • 队列长度;
  • 锁竞争信息;
  • 数据库连接是否归还;
  • 容器日志。

只有“测试超时”通常无法告诉你是锁死、连接池耗尽还是任务异常退出。


8.5 assertTimeoutassertTimeoutPreemptively

JUnit 提供:

assertTimeout(Duration.ofSeconds(1), () -> service.run());

它通常等待执行体结束后判断是否超时,不会强行中断执行体。

assertTimeoutPreemptively(Duration.ofSeconds(1), () -> service.run());

它可能在超时后提前终止执行线程。提前终止有风险:

  • 被测代码可能没有处理中断;
  • 数据库事务可能没有正常回滚;
  • ThreadLocal 上下文可能遗留;
  • 锁和外部资源可能处于不完整状态;
  • Spring 事务上下文通常绑定测试线程,换线程执行会改变语义。

因此,对数据库、事务、线程池和不可中断 I/O 的测试,优先使用普通 assertTimeout,同时给底层等待设置合理超时。


8.6 竞态测试的统计边界

并发测试经常有一个误解:

测试运行一次没有暴露竞态,就说明代码线程安全。

这是错误的。竞态是否出现取决于调度、CPU、编译器优化、系统负载和时间窗口。重复运行可以提高暴露概率,但不能把概率实验变成证明:

@RepeatedTest(100)
void repeatedConcurrencyCheck() throws Exception {
    // 每次构造全新状态并验证不变量
}

重复测试的注意事项:

  • 每次必须重建共享状态;
  • 线程池必须关闭;
  • 不应依赖测试方法执行顺序;
  • 失败时应记录随机种子、线程数和操作序列;
  • 不能用无限重复掩盖一个本应可证明的原子性缺陷。

需要验证 Java 内存模型级别的算法时,普通 JUnit 不是专用工具。可以考虑 OpenJDK 的 jcstress 等并发测试工具;它通过大量调度和结果分类研究可见性与重排序,和验证业务端到端结果的 JUnit 测试属于不同层次。


九、并发程序中的异常与取消

并发异常有一条额外的数据流:

工作线程抛出异常
        │
        ▼
Future 保存异常
        │
        ▼
Future.get()
        │
        ▼
ExecutionException
        │
        ▼
getCause() 得到原始异常

测试:

@Test
void propagatesWorkerFailure() throws Exception {
    try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
        Future<?> future = executor.submit(() -> {
            throw new IllegalStateException("worker failed");
        });

        ExecutionException exception = assertThrows(
                ExecutionException.class,
                future::get
        );

        assertThat(exception)
                .hasCauseInstanceOf(IllegalStateException.class)
                .hasRootCauseMessage("worker failed");
    }
}

不要只断言 ExecutionException,否则会漏掉任务失败的实际类型。

取消也有契约:

Future<?> future = executor.submit(() -> {
    try {
        while (!Thread.currentThread().isInterrupted()) {
            // 工作
        }
    } finally {
        // 清理
    }
});

future.cancel(true);

cancel(true) 只是请求中断,不保证任务立即停止。任务必须:

  • 检查中断标志;
  • 正确处理 InterruptedException
  • 在 finally 中释放资源;
  • 不要捕获 InterruptedException 后静默忽略。

测试取消时,应验证任务最终停止和资源被释放,而不是只验证 cancel(true) 返回 true


十、JUnit、Mockito 和 Testcontainers 的分层组合

测试层次应由依赖真实性和故障成本决定。

10.1 单元测试

单元测试通常:

  • 使用 JUnit Jupiter;
  • 使用 AssertJ 表达结果;
  • 使用 Mockito 替换外部依赖;
  • 不启动 Spring Context;
  • 不连接真实数据库或网络。

它适合验证业务决策:

输入订单
  ├─ 库存不足 → 领域异常,外部保存调用为 0
  └─ 库存足够 → 状态改变,保存一次

这类测试应快速、隔离,并能精确定位业务分支。

10.2 集成测试

集成测试把部分真实组件接在一起:

  • Repository + PostgreSQL;
  • HTTP 客户端 + WireMock 或真实测试服务;
  • 消息生产者 + Kafka 容器;
  • Spring Boot 应用 + Testcontainers 数据库。

此时 Mockito 不应替换被验证的那一层。例如测试 SQL 时,不应 Mock DataSource;测试支付 HTTP 协议时,不应只验证 PaymentGateway mock 被调用。

10.3 端到端测试

端到端测试验证完整链路:

HTTP 请求
  → Controller
  → Service
  → Repository
  → PostgreSQL
  → HTTP 响应

它能发现配置、序列化、事务和数据库映射问题,但故障定位较慢。因此不应把所有业务分支都堆到端到端测试中。

Spring Boot 中常见的测试 Slice,例如 @WebMvcTest@DataJpaTest,会只加载部分应用上下文;@SpringBootTest 则加载更完整的上下文。Slice 测试和 Testcontainers 可以组合,但必须确认:

  • 容器连接信息是否注入到 Spring Environment;
  • 上下文缓存是否复用了旧配置;
  • 每个测试的数据库状态是否隔离;
  • Mock Bean 是否覆盖了真实 Bean;
  • MockMvc 测的是 MVC 层,还是已经调用了真实持久化层。

十一、测试上下文缓存与共享状态

Spring 测试上下文通常会缓存相同配置的上下文,以减少启动时间。缓存带来的前提是:测试不能随意修改共享上下文状态。

以下情况可能影响隔离:

  • 修改了共享 Bean 的可变字段;
  • Mock Bean 的配置泄漏到其他测试;
  • 使用 @DirtiesContext 强制销毁上下文;
  • 容器或数据库连接在上下文缓存期间已失效;
  • 静态字段保存了测试数据。

@DirtiesContext 可以解决状态污染,但代价是上下文重建。它不是默认清理机制。更合理的做法通常是:

  • 让 Bean 尽量无状态;
  • 测试只修改自己的数据;
  • 通过事务或 schema 隔离数据库;
  • 只有确实修改了上下文结构时才标记 dirty。

十二、常见失败表现与诊断路径

12.1 测试单独通过,整套执行失败

常见原因:

  • 静态集合或单例保存了状态;
  • PER_CLASS 测试实例共享字段;
  • Testcontainers 数据未清理;
  • 测试依赖了方法执行顺序;
  • Mockito mock 在多个测试之间复用;
  • 系统时钟、默认时区或默认 Locale 被修改。

诊断方式:

mvn -Dtest=OrderServiceTest#paysOrderAndPersistsPaidState test
mvn test
mvn -Dtest=OrderServiceTest test

如果单方法通过、类内失败,优先检查测试间状态;如果类内通过、全套失败,检查静态状态、系统属性、上下文缓存和并行执行配置。


12.2 并发测试偶尔失败

常见原因:

  • 没有等待所有任务结束;
  • 忽略了 Future.get() 的异常;
  • 使用睡眠时间代替同步条件;
  • 测试依赖线程调度;
  • 共享状态未重置;
  • 代码存在真正的竞态。

以下写法不可靠:

executor.submit(task);
Thread.sleep(100);
assertThat(result).isEqualTo(expected);

100 毫秒不是同步协议。机器负载变化时,任务可能尚未执行;任务也可能已经执行但仍存在可见性问题。

应使用 Future.get()CountDownLatchCyclicBarrier 或条件等待,并为每次等待设置超时。


12.3 Mockito 测试通过,但生产仍然失败

这通常表示测试替身边界过于虚假:

  • Mock 没有验证序列化格式;
  • Mock 接受了真实客户端不会接受的参数;
  • Mock 数据库绕过了约束;
  • Mock 返回值没有模拟超时、重复消息或异常;
  • verify 验证了调用,却没有验证结果状态。

修复方向不是增加更多 verify,而是在关键边界增加真实协议测试或 Testcontainers 集成测试。


12.4 Testcontainers 在 CI 中失败

应区分以下错误:

  1. Docker daemon 不可用;
  2. 镜像无法拉取;
  3. 容器启动但健康检查失败;
  4. 端口或资源不足;
  5. 数据库已启动但迁移失败;
  6. 测试数据污染导致断言失败。

诊断顺序:

docker version
docker ps
docker pull postgres:16-alpine
mvn -Dtest=OrderDatabaseTest test

同时查看 Testcontainers 日志和失败容器日志。不要简单地增加等待时间;如果根因是错误的用户名、schema 或迁移 SQL,延迟不会修复问题。


十三、测试设计中的形式化检查

一个测试至少应明确四个部分:

Given:前置状态和输入
When:执行一个行为
Then:观察结果、异常、状态或交互

例如库存预订:

Given:库存为 1,两个并发请求各预订 1
When:两个请求在检查点同时开始
Then:
  - 恰好一个请求成功;
  - 一个请求失败;
  - 最终库存为 0;
  - 数据库中不能出现两条成功预订。

可以把正确性写成不变量:

successCount=1successCount = 1

stock0stock \ge 0

stock=initialStocksuccessfulQuantitystock = initialStock - \sum successfulQuantity

如果测试只检查:

assertThat(stock).isGreaterThanOrEqualTo(0);

它无法发现两个请求都返回成功但最终库存恰好为 0 的错误,因为真正失败的是成功记录数量和库存扣减的对应关系。

一个好的并发测试因此要同时观察:

  • 每个任务的结果;
  • 全局最终状态;
  • 外部持久化记录;
  • 错误和取消路径。

十四、从测试代码反推生产代码设计

测试困难往往暴露设计问题。

如果一个测试必须:

  • Mock 私有方法;
  • Mock 大量静态调用;
  • 反射修改私有字段;
  • 启动完整应用才能验证一个简单规则;
  • 睡眠数百毫秒等待异步结果;
  • 依赖全局系统时间;
  • 访问真实外部生产地址;

那么问题通常不在“测试工具不够强”,而在生产代码缺少边界和可注入依赖。

例如,把当前时间直接写死:

if (expiresAt.isBefore(Instant.now())) {
    // expired
}

测试会随真实时间变化。改为注入 Clock

final class ExpirationPolicy {
    private final Clock clock;

    ExpirationPolicy(Clock clock) {
        this.clock = clock;
    }

    boolean expired(Instant expiresAt) {
        return expiresAt.isBefore(Instant.now(clock));
    }
}

测试可以使用固定时钟:

@Test
void recognizesExpiredTime() {
    Instant now = Instant.parse("2025-01-01T00:00:00Z");
    Clock clock = Clock.fixed(now, ZoneOffset.UTC);

    ExpirationPolicy policy = new ExpirationPolicy(clock);

    assertThat(policy.expired(Instant.parse("2024-12-31T23:59:59Z")))
            .isTrue();
}

这里测试不再依赖机器时间、时区和执行速度,生产代码也获得了明确的时间依赖。


十五、Java 25 环境中的版本边界

Java 25 是运行时和语言基线,但 JUnit、AssertJ、Mockito、Testcontainers 各自有独立的发布周期。

应区分三件事:

  1. Java 语言和标准库保证:由 Java SE 和 Java Language Specification 定义,例如异常、线程、锁、Future、try-with-resources 的语言与库语义;
  2. 测试框架契约:由 JUnit、Mockito、AssertJ、Testcontainers 自身文档和版本定义;
  3. 实现行为:例如 Docker 镜像启动速度、Mockito 字节码代理限制、IDE 对动态测试的展示方式。

不要把某个版本的实现现象当成 Java 规范保证。例如:

  • 测试方法的默认执行顺序不应被假定;
  • 线程调度顺序不应被假定;
  • 容器启动耗时不应被假定;
  • 失败消息格式不应被当作框架永久契约;
  • 并行测试下的共享静态状态更不能依赖执行顺序。

十六、收束:按故障边界选择测试工具

JUnit 5 负责组织和执行测试,AssertJ 负责表达结果与失败条件,Mockito 负责隔离和验证可控依赖,Testcontainers 负责把真实基础设施带入测试环境,并发测试则必须建立在 Java 内存模型、同步原语和不变量之上。

它们不是互相替代的关系:

业务决策       → JUnit 5 + AssertJ + Mockito
SQL/事务/约束   → JUnit 5 + AssertJ + Testcontainers
线程安全       → JUnit 5 + 同步原语 + 不变量
协议兼容       → 集成测试/契约测试
完整链路       → Spring Boot/HTTP/容器端到端测试

最可靠的测试不是 Mock 最多、容器最多或断言最多的测试,而是准确地把断言放在故障真正可能发生的边界上:

  • 业务规则用隔离测试快速验证;
  • 外部协议用真实或协议级测试验证;
  • 数据库语义用真实数据库验证;
  • 并发安全用明确同步和全局不变量验证;
  • 异常测试验证类型、原因、关闭失败和取消语义;
  • 所有资源都必须有可观察、可诊断、可恢复的生命周期。

系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。