Java 基础体系 · 第 19/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 测试体系:JUnit 5、AssertJ、Mockito、Testcontainers 和并发测试
测试不是“调用方法后检查一个返回值”这么简单。一个可维护的 Java 测试体系至少要回答以下问题:
- 被测行为的输入、输出和失败契约是什么?
- 测试本身如何被发现、隔离、执行和报告?
- 如何表达复杂断言,而不把失败信息写成难以诊断的字符串比较?
- 如何替换外部依赖,并验证交互是否符合协议?
- 如何使用真实数据库、消息代理或其他基础设施验证集成行为?
- 如何测试并发程序中的可见性、原子性、顺序和资源关闭?
- 一个测试失败时,如何区分生产代码错误、测试替身错误、环境错误和调度偶然性?
本文以 Java 25 为编译和运行基线,使用 JUnit 5、AssertJ、Mockito、Testcontainers,并从 Java 内存模型、异常契约和资源生命周期出发构建一套完整的测试方法。
一、先定义测试对象:行为、状态与协议
测试的基本单位不是“类”,而是一个可观察的行为。
对于一个操作:
可以把它理解为:
- :调用前的系统状态;
- :输入;
- :调用后的系统状态;
- :正常输出;
- :异常或其他失败结果。
例如,一个扣减库存的操作可以描述为:
- 输入:商品编号
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);
}
}
这里有三类断言:
order.status():业务状态;verify(paymentGateway):支付协议;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 正确;
- 消息代理启动,不代表主题或队列已存在。
因此集成测试需要明确初始化顺序:
- 启动容器;
- 等待服务就绪;
- 创建 schema 或运行 Flyway/Liquibase 迁移;
- 插入测试所需的最小数据;
- 执行被测操作;
- 检查状态和约束;
- 清理数据或销毁容器。
对于 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;
}
}
并发测试的关键不是检查某一次运行的中间值,而是检查不变量:
测试:
@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() 有两个作用:
- 等待任务结束;
- 重新抛出任务内的异常。
如果不调用 get(),工作线程中的异常可能不会让测试失败,测试只会在主线程读取结果时继续执行。
Java 25 中 ExecutorService 可用于 try-with-resources;关闭线程池并不等价于验证所有任务已成功,因此仍需要保留并检查 Future。
8.3 用屏障制造指定的交错
并发测试不能依赖线程“碰巧同时执行”。CyclicBarrier 或 CountDownLatch 可以建立确定的测试阶段。
假设一个库存扣减操作必须保证库存不为负:
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;
}
}
把检查和扣减放进同一临界区,保证:
作为一个不可分割的操作执行。仅仅给 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 assertTimeout 与 assertTimeoutPreemptively
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()、CountDownLatch、CyclicBarrier 或条件等待,并为每次等待设置超时。
12.3 Mockito 测试通过,但生产仍然失败
这通常表示测试替身边界过于虚假:
- Mock 没有验证序列化格式;
- Mock 接受了真实客户端不会接受的参数;
- Mock 数据库绕过了约束;
- Mock 返回值没有模拟超时、重复消息或异常;
verify验证了调用,却没有验证结果状态。
修复方向不是增加更多 verify,而是在关键边界增加真实协议测试或 Testcontainers 集成测试。
12.4 Testcontainers 在 CI 中失败
应区分以下错误:
- Docker daemon 不可用;
- 镜像无法拉取;
- 容器启动但健康检查失败;
- 端口或资源不足;
- 数据库已启动但迁移失败;
- 测试数据污染导致断言失败。
诊断顺序:
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;
- 数据库中不能出现两条成功预订。
可以把正确性写成不变量:
如果测试只检查:
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 各自有独立的发布周期。
应区分三件事:
- Java 语言和标准库保证:由 Java SE 和 Java Language Specification 定义,例如异常、线程、锁、
Future、try-with-resources 的语言与库语义; - 测试框架契约:由 JUnit、Mockito、AssertJ、Testcontainers 自身文档和版本定义;
- 实现行为:例如 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 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:JVM 诊断与性能:JFR、JMC、jcmd、线程转储、堆转储和证据链
- 下一篇:Java 日志与配置:SLF4J、Logback、结构化字段、Secret 和动态治理
- 延伸:Java 异常处理:受检异常、错误链、资源关闭和 API 契约
- 延伸:Spring Boot 测试:Slice、上下文缓存、MockMvc、容器和契约测试
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论