在现代 Java 工程中,测试不仅是质量保障的最后防线,更是驱动设计(TDD)与持续交付的核心环节。本文从测试金字塔出发,系统讲解 JUnit 5、Mockito、AssertJ、JaCoCo、Testcontainers 与 JMH 的实战用法,助你构建多层次、高覆盖、可信赖的 Java 测试体系。
1. 测试金字塔
测试金字塔将测试分为三层:单元测试位于塔底,数量最多、运行最快;集成测试居中,验证模块间协作;端到端测试在塔尖,覆盖完整用户路径但成本最高。
/\
/ \ 端到端测试(E2E)—— 少而精
/____\
/ \ 集成测试 —— 中等数量
/________\
/ \ 单元测试 —— 大量、快速
/____________\
单元测试应覆盖核心业务逻辑,不依赖外部系统。集成测试验证数据库、缓存、消息队列等真实依赖。端到端测试通常通过 REST API 或浏览器自动化模拟用户操作。遵循金字塔分层,可兼顾反馈速度与缺陷发现能力。平均而言,单元测试应占总数的 70% 以上,集成测试 20%,端到端测试 10%。
| 层级 | 范围 | 依赖 | 执行速度 | 维护成本 |
|---|---|---|---|---|
| 单元测试 | 单个类或方法 | 无外部依赖(全部 mock) | 毫秒级 | 低 |
| 集成测试 | 多模块协作 | 真实数据库、缓存等 | 秒级 | 中 |
| 端到端测试 | 完整用户流程 | 完整应用环境 | 分钟级 | 高 |
保持单元测试独立、快速、可重复,是金字塔稳固的基石。任何一层失衡都会导致构建时间膨胀或缺陷漏网。
2. JUnit 5 基础与参数化测试
2.1 基础注解
JUnit 5 由 JUnit Platform、JUnit Jupiter 与 JUnit Vintage 三部分组成。Jupiter 提供新的编程模型与扩展机制。
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* JUnit 5 基础生命周期与断言演示
*/
class CalculatorTest {
// 在所有测试之前执行一次,常用于初始化共享资源
@BeforeAll
static void setUpAll() {
System.out.println("初始化数据库连接池...");
}
// 每个测试方法之前执行,常用于创建干净的被测对象
@BeforeEach
void setUp() {
// 准备测试上下文
}
@Test
@DisplayName("两数相加应返回正确结果")
void add_ShouldReturnSum() {
Calculator calc = new Calculator();
int result = calc.add(2, 3);
assertEquals(5, result, "2 + 3 应该等于 5");
}
@Test
@DisplayName("除数为零时应抛出算术异常")
void divide_ByZero_ShouldThrowException() {
Calculator calc = new Calculator();
// 验证异常类型与消息内容
ArithmeticException ex = assertThrows(ArithmeticException.class,
() -> calc.divide(1, 0));
assertTrue(ex.getMessage().contains("zero"));
}
// 禁用某个测试,常用于等待修复的缺陷场景
@Disabled("等待需求澄清后再启用")
@Test
void pendingTest() {
// 待实现
}
@AfterEach
void tearDown() {
// 清理每个测试的临时数据
}
@AfterAll
static void tearDownAll() {
System.out.println("关闭数据库连接池...");
}
}
常用断言方法包括 assertEquals、assertTrue、assertNull、assertThrows 等。使用 @DisplayName 为测试赋予可读性强的中文名称,测试报告更容易理解。
2.2 参数化测试
参数化测试允许用多组数据驱动同一个测试逻辑,避免重复代码。
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
/**
* 参数化测试:用不同输入批量验证同一行为
*/
class ParameterizedCalculatorTest {
// 使用 @ValueSource 提供简单值序列
@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "level" })
@DisplayName("回文字符串验证")
void isPalindrome_ShouldReturnTrue(String candidate) {
assertTrue(StringUtils.isPalindrome(candidate));
}
// 使用 @CsvSource 提供多列 CSV 数据
@ParameterizedTest(name = "[{index}] {0} + {1} = {2}")
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, 20, 30",
"-1, 1, 0"
})
void add_WithCsvSource_ShouldCalculateCorrectly(int a, int b, int expected) {
Calculator calc = new Calculator();
assertEquals(expected, calc.add(a, b));
}
// 使用 @MethodSource 引用静态工厂方法
static Stream<Arguments> provideStringsForIsBlank() {
return Stream.of(
Arguments.of(null, true),
Arguments.of("", true),
Arguments.of(" ", true),
Arguments.of("not blank", false)
);
}
@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
void isBlank_ShouldReturnExpected(String input, boolean expected) {
assertEquals(expected, StringUtils.isBlank(input));
}
// 使用 @EnumSource 枚举值作为输入
@ParameterizedTest
@EnumSource(TimeUnit.class)
void allTimeUnits_ShouldHavePositiveMillis(TimeUnit unit) {
assertTrue(unit.toMillis(1) > 0);
}
}
参数化测试的数据源灵活多样,适合边界值分析、等价类划分等黑盒测试方法。配合 name 属性自定义每条用例的显示名称,失败时可快速定位问题数据。
3. Mockito 核心特性
Mockito 是 Java 最流行的模拟框架,能在不依赖真实实现的情况下验证对象交互。
3.1 Mock、Stub 与 Verify
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* Mockito 基础:模拟依赖、打桩返回值、验证交互
*/
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private PaymentGateway paymentGateway; // 模拟支付网关
@Mock
private InventoryService inventoryService; // 模拟库存服务
@InjectMocks
private OrderService orderService; // 自动注入 mock 依赖
@Test
@DisplayName("下单成功时应调用支付和扣减库存")
void placeOrder_Success_ShouldProcessPaymentAndDeductStock() {
// 准备:定义 mock 行为(stubbing)
when(inventoryService.checkStock("SKU-001", 2)).thenReturn(true);
when(paymentGateway.charge("user-123", new BigDecimal("199.99")))
.thenReturn(new PaymentResult(true, "TXN-888"));
// 执行:调用被测方法
OrderResult result = orderService.placeOrder("user-123", "SKU-001", 2);
// 断言:验证业务结果
assertTrue(result.isSuccess());
assertEquals("TXN-888", result.getTransactionId());
// 验证:确认交互次数与参数
verify(inventoryService).checkStock("SKU-001", 2);
verify(inventoryService).deductStock("SKU-001", 2);
verify(paymentGateway).charge(eq("user-123"), any(BigDecimal.class));
verifyNoMoreInteractions(paymentGateway); // 确保没有多余调用
}
@Test
@DisplayName("库存不足时不应调用支付")
void placeOrder_InsufficientStock_ShouldNotCharge() {
when(inventoryService.checkStock("SKU-002", 5)).thenReturn(false);
OrderResult result = orderService.placeOrder("user-456", "SKU-002", 5);
assertFalse(result.isSuccess());
// 验证支付从未被调用
verify(paymentGateway, never()).charge(anyString(), any());
}
}
@ExtendWith(MockitoExtension.class) 启用 Mockito 对 JUnit 5 的支持。@Mock 自动创建模拟对象,@InjectMocks 尝试通过构造器或字段注入依赖。when(...).thenReturn(...) 定义打桩行为,verify(...) 验证方法是否按预期被调用。
3.2 Spy 与部分模拟
Spy 包装真实对象,可以保留原有行为的同时重写特定方法。
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
/**
* Spy 示例:部分模拟真实对象
*/
class SpyDemoTest {
@Test
@DisplayName("spy 可监控真实方法调用并重写特定方法")
void spy_ShouldAllowPartialMocking() {
List<String> realList = new ArrayList<>();
List<String> spyList = spy(realList);
// 真实方法生效:数据加入列表
spyList.add("one");
spyList.add("two");
assertEquals(2, spyList.size());
// 打桩:让 size() 返回固定值
doReturn(100).when(spyList).size();
assertEquals(100, spyList.size()); // 返回打桩值
// verify 验证真实调用发生
verify(spyList).add("one");
verify(spyList).add("two");
}
@Test
@DisplayName("spy 监控具体实例行为")
void spyOnConcreteClass() {
OrderProcessor processor = new OrderProcessor();
OrderProcessor spyProcessor = spy(processor);
// 仅模拟 validate 方法,其余保持真实逻辑
doReturn(true).when(spyProcessor).validate(any());
spyProcessor.process(new Order());
// 验证 process 内部调用了 validate
verify(spyProcessor).validate(any());
}
}
Spy 适合遗留代码的渐进式改造场景。注意:对 spy 打桩时应优先使用 doReturn(...).when(spy) 语法,避免触发真实方法产生副作用。
3.3 ArgumentCaptor 捕获参数
当需要验证方法传入的具体参数内容时,ArgumentCaptor 非常有用。
import org.mockito.ArgumentCaptor;
import static org.mockito.ArgumentCaptor.forClass;
/**
* ArgumentCaptor 捕获并验证传入参数
*/
class ArgumentCaptorTest {
@Mock
private NotificationService notificationService;
@Test
@DisplayName("应发送包含正确内容的邮件通知")
void orderShipped_ShouldSendEmailWithCorrectContent() {
OrderService service = new OrderService(notificationService);
Order order = Order.builder()
.id("ORD-999")
.customerEmail("alice@example.com")
.total(new BigDecimal("299.00"))
.build();
service.notifyShipped(order);
// 捕获传入的 EmailMessage 对象
ArgumentCaptor<EmailMessage> captor = forClass(EmailMessage.class);
verify(notificationService).sendEmail(captor.capture());
EmailMessage sentMessage = captor.getValue();
assertEquals("alice@example.com", sentMessage.getTo());
assertTrue(sentMessage.getBody().contains("ORD-999"));
assertTrue(sentMessage.getBody().contains("299.00"));
}
@Test
@DisplayName("批量通知时应捕获多条消息")
void bulkNotify_ShouldSendMultipleEmails() {
List<Order> orders = List.of(
Order.builder().id("O1").customerEmail("a@x.com").build(),
Order.builder().id("O2").customerEmail("b@x.com").build()
);
OrderService service = new OrderService(notificationService);
service.bulkNotifyShipped(orders);
ArgumentCaptor<EmailMessage> captor = forClass(EmailMessage.class);
verify(notificationService, times(2)).sendEmail(captor.capture());
List<EmailMessage> allMessages = captor.getAllValues();
assertEquals(2, allMessages.size());
assertEquals("O1", allMessages.get(0).getOrderId());
assertEquals("O2", allMessages.get(1).getOrderId());
}
}
ArgumentCaptor 支持 getValue()(单次调用)和 getAllValues()(多次调用)。结合 times(n)、atLeast(n)、atMost(n) 可精确控制验证次数。
4. Mockito 高级特性
4.1 静态方法模拟
Mockito 3.4.0+ 支持模拟静态方法,依赖 mockito-inline 引擎。
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import static org.mockito.Mockito.*;
/**
* 静态方法模拟:处理工具类或遗留代码中的静态调用
*/
class StaticMockTest {
@Test
@DisplayName("模拟 UUID 随机生成为可预测值")
void mockStaticUuid_ShouldReturnFixedValue() {
try (MockedStatic<UUID> mockedUuid = mockStatic(UUID.class)) {
// 所有 UUID.randomUUID() 调用返回固定值
mockedUuid.when(UUID::randomUUID)
.thenReturn(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"));
UUID result = UUID.randomUUID();
assertEquals("123e4567-e89b-12d3-a456-426614174000", result.toString());
}
// try-with-resources 结束后静态模拟自动释放
}
@Test
@DisplayName("模拟 LocalDateTime.now() 实现时间旅行测试")
void mockStaticLocalDateTime_ShouldControlTime() {
LocalDateTime fixedTime = LocalDateTime.of(2026, 9, 1, 10, 0);
try (MockedStatic<LocalDateTime> mocked = mockStatic(LocalDateTime.class)) {
mocked.when(LocalDateTime::now).thenReturn(fixedTime);
LocalDateTime now = LocalDateTime.now();
assertEquals(fixedTime, now);
// 验证静态方法被调用
mocked.verify(LocalDateTime::now);
}
}
}
静态模拟应谨慎使用,过度依赖说明代码存在静态方法的过度使用,应考虑重构为实例方法。
4.2 构造器模拟
Mockito 支持模拟对象构造过程,适用于 new 关键字创建依赖的场景。
import org.junit.jupiter.api.Test;
import org.mockito.MockedConstruction;
import static org.mockito.Mockito.*;
/**
* 构造器模拟:控制 new 关键字创建的对象
*/
class ConstructionMockTest {
@Test
@DisplayName("模拟 HttpClient 构造与请求行为")
void mockConstruction_ShouldControlNewInstances() {
try (MockedConstruction<HttpClient> mocked = mockConstruction(HttpClient.class,
(mock, context) -> {
// 上下文包含构造参数等信息
when(mock.send(any(), any())).thenReturn("mocked response");
})) {
// 此处的 new HttpClient() 返回 mock 对象
HttpClient client = new HttpClient("https://api.example.com");
String response = client.send("GET", Map.of());
assertEquals("mocked response", response);
assertEquals(1, mocked.constructed().size()); // 验证构造次数
}
}
@Test
@DisplayName("多实例构造场景验证")
void multipleInstances_ShouldTrackSeparately() {
try (MockedConstruction<DatabaseConnection> mocked = mockConstruction(
DatabaseConnection.class)) {
DatabaseConnection conn1 = new DatabaseConnection("jdbc:mysql://host1");
DatabaseConnection conn2 = new DatabaseConnection("jdbc:mysql://host2");
// 每个实例都是独立的 mock
when(conn1.query("SELECT 1")).thenReturn("result1");
when(conn2.query("SELECT 1")).thenReturn("result2");
assertEquals("result1", conn1.query("SELECT 1"));
assertEquals("result2", conn2.query("SELECT 1"));
assertEquals(2, mocked.constructed().size());
}
}
}
构造器模拟适合第三方库无法注入的场景,但滥用会降低代码可维护性。优先通过依赖注入与工厂模式解耦,仅在必要时刻启用构造器模拟。
5. AssertJ 流式断言
AssertJ 提供流畅的链式 API,使测试断言更接近自然语言,失败信息也更详尽。
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
import java.util.*;
/**
* AssertJ 流式断言全面演示
*/
class AssertJDemoTest {
@Test
@DisplayName("基础类型断言")
void basicAssertions() {
assertThat("Hello, AssertJ")
.isNotEmpty()
.startsWith("Hello")
.contains("AssertJ")
.hasSize(17);
assertThat(42)
.isPositive()
.isGreaterThan(10)
.isLessThanOrEqualTo(100);
}
@Test
@DisplayName("集合与对象断言")
void collectionAndObjectAssertions() {
List<String> names = List.of("Alice", "Bob", "Charlie");
assertThat(names)
.hasSize(3)
.contains("Bob")
.doesNotContain("David")
.containsExactlyInAnyOrder("Charlie", "Alice", "Bob");
User user = new User("alice", "alice@example.com", 25);
assertThat(user)
.extracting(User::getName, User::getAge)
.containsExactly("alice", 25);
assertThat(user)
.matches(u -> u.getEmail().contains("@"), "邮箱应包含 @ 符号");
}
@Test
@DisplayName("异常断言")
void exceptionAssertions() {
assertThatThrownBy(() -> calculator.divide(1, 0))
.isInstanceOf(ArithmeticException.class)
.hasMessageContaining("zero");
// 更精确的异常类型断言
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> validator.validate(null))
.withMessage("输入不能为空");
}
@Test
@DisplayName("自定义条件与过滤")
void customConditions() {
List<User> users = List.of(
new User("a", "a@test.com", 20),
new User("b", "b@test.com", 30),
new User("c", "c@test.com", 40)
);
assertThat(users)
.filteredOn(u -> u.getAge() >= 30)
.hasSize(2)
.extracting(User::getName)
.containsExactly("b", "c");
// 使用自定义描述提升可读性
assertThat(users)
.as("用户列表检查")
.allMatch(u -> u.getEmail().endsWith("@test.com"));
}
@Test
@DisplayName("字段级对象比较")
void fieldByFieldComparison() {
User expected = new User("alice", "alice@test.com", 25);
User actual = fetchUserFromDatabase();
// 递归比较忽略特定字段
assertThat(actual)
.usingRecursiveComparison()
.ignoringFields("createdAt", "updatedAt")
.isEqualTo(expected);
}
}
AssertJ 的 assertThatThrownBy、usingRecursiveComparison、filteredOn 等高级特性大幅提升了复杂对象与异常场景的测试表达力。失败时自动展开的 diff 信息也能加速问题定位。
6. JaCoCo 覆盖率配置
JaCoCo 是 Java 的事实标准覆盖率工具,可生成行、分支、方法、类等多维度报告。
6.1 Maven 配置
<!-- pom.xml 中配置 JaCoCo 插件 -->
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<!-- 测试开始前挂载代理 -->
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<!-- 测试完成后生成报告 -->
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<!-- 设置覆盖率阈值,不达标则构建失败 -->
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
运行 mvn test 后,target/site/jacoco/index.html 即包含交互式 HTML 报告。红线与绿线直观标注未覆盖代码,分支覆盖率尤其能发现遗漏的条件组合。
6.2 Gradle 配置
// build.gradle 中应用 JaCoCo 插件
plugins {
id 'java'
id 'jacoco'
}
jacoco {
toolVersion = "0.8.11"
}
test {
useJUnitPlatform()
finalizedBy jacocoTestReport // 测试后自动生成报告
}
jacocoTestReport {
dependsOn test
reports {
xml.required = true // CI 工具通常需要 XML 格式
html.required = true // 本地查看用
csv.required = false
}
}
// 设置覆盖率检查规则
jacocoTestCoverageVerification {
violationRules {
rule {
element = 'BUNDLE'
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.80
}
limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.70
}
}
rule {
element = 'CLASS'
limit {
counter = 'METHOD'
value = 'COVEREDRATIO'
minimum = 0.60
}
}
}
}
check.dependsOn jacocoTestCoverageVerification
通过 jacocoTestCoverageVerification 任务,可在持续集成流水线中强制要求新代码达到覆盖率门槛。结合 excludes 可排除 DTO、配置类等无需测试的代码。
7. Spring Boot 集成测试
Spring Boot 的 @SpringBootTest 启动完整应用上下文,支持真实 HTTP 调用与 slice 测试两种模式。
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* Spring Boot 完整集成测试:验证 REST API 层到数据层的完整链路
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional // 每个测试后自动回滚数据库变更
class OrderControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private OrderRepository orderRepository;
@Test
@DisplayName("创建订单接口应返回 201 并 persisted 数据")
void createOrder_ShouldReturn201AndPersistData() throws Exception {
String requestBody = """
{
"userId": "U-100",
"sku": "SKU-999",
"quantity": 3,
"price": 99.99
}
""";
// 执行 HTTP POST 请求
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.orderId").exists())
.andExpect(jsonPath("$.status").value("PENDING"));
// 验证数据已写入数据库
List<Order> orders = orderRepository.findByUserId("U-100");
assertThat(orders).hasSize(1);
assertThat(orders.get(0).getSku()).isEqualTo("SKU-999");
}
@Test
@DisplayName("查询订单应返回正确数据结构")
void getOrder_ShouldReturnOrderDetails() throws Exception {
// 预置测试数据
Order saved = orderRepository.save(
Order.builder().userId("U-200").sku("SKU-001").quantity(1).build()
);
mockMvc.perform(get("/api/orders/{id}", saved.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.sku").value("SKU-001"))
.andExpect(jsonPath("$.quantity").value(1));
}
}
MockMvc 在无需启动真实 Servlet 容器的情况下模拟 HTTP 调用,执行速度快。@Transactional 保证测试隔离性,但注意在涉及异步消息时可能不适用。
7.1 Slice 测试
当只需测试某一层(如 Repository 或 Web 层)时,使用 @DataJpaTest、@WebMvcTest 等注解加速启动。
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
/**
* Repository 层切片测试:仅加载 JPA 相关配置
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositorySliceTest {
@Autowired
private OrderRepository orderRepository;
@Test
@DisplayName("应按用户 ID 正确查询订单列表")
void findByUserId_ShouldReturnMatchingOrders() {
orderRepository.save(Order.builder().userId("U-1").sku("A").build());
orderRepository.save(Order.builder().userId("U-1").sku("B").build());
orderRepository.save(Order.builder().userId("U-2").sku("C").build());
List<Order> result = orderRepository.findByUserId("U-1");
assertThat(result).hasSize(2);
assertThat(result).extracting(Order::getSku).containsExactlyInAnyOrder("A", "B");
}
}
@DataJpaTest 默认使用嵌入式数据库。通过 Replace.NONE 可连接真实测试数据库,配合 Testcontainers 实现环境一致性。
8. Testcontainers 容器化集成测试
Testcontainers 在测试期间自动拉取并运行 Docker 容器,为集成测试提供真实的外部依赖。
8.1 基础配置
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
/**
* Testcontainers + PostgreSQL:真实数据库集成测试
*/
@Testcontainers
@SpringBootTest
class PostgresIntegrationTest {
// 声明并启动 PostgreSQL 容器
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine"))
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass");
// 动态将容器连接信息注入 Spring 环境
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("用户 CRUD 操作在真实 Postgres 中验证")
void userCrud_ShouldWorkWithRealDatabase() {
User user = User.builder()
.name("Test User")
.email("test@example.com")
.build();
User saved = userRepository.save(user);
assertThat(saved.getId()).isNotNull();
User found = userRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getEmail()).isEqualTo("test@example.com");
}
}
@Testcontainers 启用 JUnit 5 扩展,@Container 标注的容器在测试前后自动管理生命周期。@DynamicPropertySource seamless 地将容器实际端口和凭据注入 Spring 配置,无需硬编码。
8.2 Redis 容器测试
import org.testcontainers.containers.GenericContainer;
import org.springframework.data.redis.core.StringRedisTemplate;
/**
* Testcontainers + Redis:缓存层集成验证
*/
@Testcontainers
@SpringBootTest
class RedisIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>(
DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", redis::getFirstMappedPort);
}
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private CacheService cacheService;
@Test
@DisplayName("缓存写入与读取应通过真实 Redis 验证")
void cacheOps_ShouldPersistInRedis() {
String key = "product:1001";
String value = "{\"name\":\"MacBook Pro\",\"price\":19999}";
cacheService.putProductCache(key, value);
String cached = cacheService.getProductCache(key);
assertThat(cached).isEqualTo(value);
// 直接通过 RedisTemplate 验证底层数据
String direct = redisTemplate.opsForValue().get(key);
assertThat(direct).isEqualTo(value);
// 验证 TTL 设置生效
Long ttl = redisTemplate.getExpire(key);
assertThat(ttl).isPositive();
}
@Test
@DisplayName("缓存失效逻辑应正确清除 Redis 键")
void cacheEvict_ShouldRemoveKey() {
String key = "product:1002";
redisTemplate.opsForValue().set(key, "test-value");
cacheService.invalidateProductCache(key);
Boolean exists = redisTemplate.hasKey(key);
assertThat(exists).isFalse();
}
}
Testcontainers 支持 PostgreSQL、MySQL、MongoDB、Kafka、Elasticsearch 等主流中间件。通过 withClasspathResourceMapping 可加载初始化 SQL 脚本,withCommand 自定义启动参数,灵活适配各种测试场景。
8.3 多容器编排
import org.testcontainers.containers.Network;
/**
* 多容器网络场景:应用 + 数据库 + Redis 同时运行
*/
@Testcontainers
@SpringBootTest
class MultiContainerIntegrationTest {
static Network network = Network.newNetwork();
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withNetwork(network)
.withNetworkAliases("postgres");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7")
.withNetwork(network)
.withNetworkAliases("redis")
.withExposedPorts(6379);
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url",
() -> "jdbc:postgresql://localhost:" + postgres.getMappedPort(5432) + "/testdb");
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", redis::getFirstMappedPort);
}
@Test
@DisplayName("端到端订单创建应涉及数据库与缓存")
void endToEndOrderFlow_ShouldPersistAndCache() {
// 集成验证完整业务链路
}
}
使用共享 Network 可使容器间通过服务名通信,更贴近生产环境的 Docker Compose 编排模式。
9. 性能测试:JMH
JMH(Java Microbenchmark Harness)是 OpenJDK 官方出品的微基准测试框架,可避免 JIT 优化、GC 干扰等陷阱。
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* JMH 微基准测试:对比字符串拼接与 StringBuilder 性能差异
*/
@BenchmarkMode(Mode.Throughput) // 测量单位时间内的操作次数
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 结果以毫秒为单位输出
@State(Scope.Thread) // 每个线程持有独立状态
@Warmup(iterations = 3, time = 1) // 预热 3 轮,每轮 1 秒
@Measurement(iterations = 5, time = 1) // 正式测量 5 轮
@Fork(2) // 在 2 个独立 JVM 进程运行
public class StringConcatBenchmark {
private final int count = 100;
/**
* 字符串拼接:编译器可能在简单场景优化为 StringBuilder
*/
@Benchmark
public String stringConcatenation() {
String result = "";
for (int i = 0; i < count; i++) {
result += i; // 每次循环创建新 String 对象
}
return result;
}
/**
* 显式使用 StringBuilder,复用内部字符数组
*/
@Benchmark
public String stringBuilderConcat() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < count; i++) {
sb.append(i);
}
return sb.toString();
}
/**
* 预估计容量的 StringBuilder,避免数组扩容
*/
@Benchmark
public String sizedStringBuilder() {
// 预分配足够容量,减少数组复制开销
StringBuilder sb = new StringBuilder(count * 5);
for (int i = 0; i < count; i++) {
sb.append(i);
}
return sb.toString();
}
public static void main(String[] args) throws Exception {
Options opt = new OptionsBuilder()
.include(StringConcatBenchmark.class.getSimpleName())
.build();
new Runner(opt).run(); // 启动基准测试
}
}
JMH 的核心注解含义如下:
@BenchmarkMode:指定测量指标(吞吐量、平均时间、采样等)@State:定义状态作用域(Thread、Benchmark、Group)@Setup/@TearDown:在基准运行前后准备和清理资源@Param:参数化基准测试,自动生成组合矩阵
运行结果示例(以吞吐量为指标):
Benchmark Mode Cnt Score Error Units
StringConcatBenchmark.stringConcatenation thrpt 10 15.234 ± 1.234 ops/ms
StringConcatBenchmark.stringBuilderConcat thrpt 10 234.567 ± 5.678 ops/ms
StringConcatBenchmark.sizedStringBuilder thrpt 10 312.891 ± 8.901 ops/ms
另一个 JMH 示例:对比 HashMap 与 ConcurrentHashMap 的读取性能。
import org.openjdk.jmh.annotations.*;
import java.util.*;
import java.util.concurrent.*;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class MapReadBenchmark {
@Param({"100", "10000", "1000000"})
private int size;
private Map<Integer, String> hashMap;
private Map<Integer, String> concurrentMap;
@Setup
public void setUp() {
hashMap = new HashMap<>(size);
concurrentMap = new ConcurrentHashMap<>(size);
for (int i = 0; i < size; i++) {
String value = "value-" + i;
hashMap.put(i, value);
concurrentMap.put(i, value);
}
}
@Benchmark
public String hashMapRead() {
return hashMap.get(size / 2); // 读取中间键
}
@Benchmark
public String concurrentHashMapRead() {
return concurrentMap.get(size / 2);
}
}
JMH 适用场景包括算法选型、数据结构对比、并发策略评估等。切勿在 JMH 中包含 I/O、网络或数据库操作,这些应交由专门的负载测试工具(如 Gatling、JMeter)处理。
10. 最佳实践
测试命名清晰:使用
given_when_then或methodName_condition_expectedResult风格,例如transfer_money_insufficientBalance_shouldThrowException。避免test1、testMethod等模糊名称。单一职责原则:每个测试只验证一个概念。避免一个测试方法里写多个断言语句检查不同行为,否则失败时定位困难。
Arrange-Act-Assert 结构:显式用空行分隔准备、执行、断言三段,提升可读性与维护性。
避免逻辑控制:测试代码中不要使用
if、for、switch。逻辑控制意味着测试本身可能存在缺陷,也可能测试了过多场景。不要依赖执行顺序:JUnit 5 默认不保证测试方法顺序,每个测试应独立设置前置条件,而非依赖其他测试的副作用。
合理使用
@DisplayName:为每个测试赋予业务语言描述,让测试报告对非技术人员也有价值。区分单元与集成测试目录:Maven 中使用
src/test/java存单元测试,src/integration-test/java存集成测试,通过 profile 分别执行,加快本地反馈。构造器注入优于字段注入:Spring 的字段注入在单元测试中需要反射或 Spring 上下文,构造器注入则可直接在测试中
new被测对象并传入 mock。数据库测试使用 @Transactional 或 Testcontainers:本地测试用
@Transactional回滚保证隔离,CI 环境用 Testcontainers 确保与生产一致。定期审查覆盖率报告:覆盖率不是目标而是指标,关注未覆盖的分支与边界条件,而非单纯追求 100% 数字。
Mock 外部系统,Stub 内部依赖:Mockito 适合模拟不可控的外部服务(支付、短信网关),而内部服务可通过 stub 提供返回值,避免过度模拟。
保持测试运行速度:单元测试应在毫秒级完成。若测试变慢,开发者会跳过执行,失去保护网意义。
使用 AssertJ 替代 JUnit 断言:流式 API 更易读、错误信息更丰富,且支持复杂对象比较与异常断言。
避免静态方法模拟:频繁使用
Mockito.mockStatic意味着架构存在依赖管理问题,优先重构为依赖注入或策略模式。CI 集成 JaCoCo 门禁:在 Pull Request 阶段拒绝降低覆盖率的提交,同时结合 SonarQube 检测代码异味与重复。
FAQ
Q: Mockito 中 when(...).thenReturn(...) 与 doReturn(...).when(...) 有什么区别?
A: when(...).thenReturn(...) 会真实调用被 mock 对象的方法一次,适用于纯 mock 对象。doReturn(...).when(...) 不触发真实方法调用,适用于 spy 对象或需要避免 void 方法副作用的场景。对 spy 打桩时推荐后者,防止执行原始实现产生意外行为。
Q: JUnit 5 的 @BeforeEach 和 @BeforeAll 能否同时使用?
A: 可以同时使用。@BeforeAll 在全部测试开始前执行一次,必须是静态方法(除非使用 @TestInstance(Lifecycle.PER_CLASS))。@BeforeEach 在每个测试方法前执行。两者生命周期互补,常用于分层初始化:连接池在 @BeforeAll 中建立,测试数据在 @BeforeEach 中准备。
Q: Testcontainers 在 CI 中需要特权模式吗?
A: Testcontainers 需要 Docker 环境。GitHub Actions、GitLab CI 的 Docker-in-Docker 服务或 Docker socket 挂载均可支持。大多数现代 CI 已内置 Docker,无需额外特权。若使用 Kubernetes 执行器,需确保 Pod 具备 SYS_ADMIN capability 或直接使用 DinD sidecar。
Q: JaCoCo 覆盖率达标但仍有 Bug 漏网,为什么?
A: 覆盖率衡量代码是否被执行,不衡量断言是否验证了正确行为。空洞的测试(只调用方法不验证结果)可提升行覆盖率,但无法发现缺陷。应关注分支覆盖、变异测试(PIT)以及业务边界条件的断言完整性。
Q: Spring Boot 测试中 @MockBean 与 Mockito 的 @Mock 如何选择?
A: @MockBean 是 Spring Boot Test 提供的注解,将 mock 对象注册到 Spring 上下文中,替换同类型的真实 Bean,适合集成测试与 slice 测试。@Mock 是 Mockito 的原生注解,仅在当前测试类中有效,不改动 Spring 上下文,适合纯粹的单元测试。若需要 Spring 管理被测对象的依赖注入,用 @MockBean;若手动构造被测对象,用 @Mock。
结语
从单元测试的细粒度快速反馈,到 Testcontainers 的真实环境验证,Java 测试生态提供了全链路的工具支撑。以测试金字塔为指导,结合 JUnit 5 的灵活参数化、Mockito 的交互验证、AssertJ 的流式表达、JaCoCo 的质量门禁与 JMH 的性能基准,能够构建既快速又可信的持续交付流水线。测试不是开发的负担,而是设计品质的放大器——写在前面的断言,往往是最可靠的文档与最坚固的防护网。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。