Java 测试策略:JUnit 5、Mockito、AssertJ 与 Testcontainers

Java 测试金字塔到端到端:JUnit 5 参数化、Mockito 高级特性、AssertJ 流式断言、JaCoCo 覆盖率与 Testcontainers 容器化集成测试实战指南。

在现代 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("关闭数据库连接池...");
    }
}

常用断言方法包括 assertEqualsassertTrueassertNullassertThrows 等。使用 @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 的 assertThatThrownByusingRecursiveComparisonfilteredOn 等高级特性大幅提升了复杂对象与异常场景的测试表达力。失败时自动展开的 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. 最佳实践

  1. 测试命名清晰:使用 given_when_thenmethodName_condition_expectedResult 风格,例如 transfer_money_insufficientBalance_shouldThrowException。避免 test1testMethod 等模糊名称。

  2. 单一职责原则:每个测试只验证一个概念。避免一个测试方法里写多个断言语句检查不同行为,否则失败时定位困难。

  3. Arrange-Act-Assert 结构:显式用空行分隔准备、执行、断言三段,提升可读性与维护性。

  4. 避免逻辑控制:测试代码中不要使用 ifforswitch。逻辑控制意味着测试本身可能存在缺陷,也可能测试了过多场景。

  5. 不要依赖执行顺序:JUnit 5 默认不保证测试方法顺序,每个测试应独立设置前置条件,而非依赖其他测试的副作用。

  6. 合理使用 @DisplayName:为每个测试赋予业务语言描述,让测试报告对非技术人员也有价值。

  7. 区分单元与集成测试目录:Maven 中使用 src/test/java 存单元测试,src/integration-test/java 存集成测试,通过 profile 分别执行,加快本地反馈。

  8. 构造器注入优于字段注入:Spring 的字段注入在单元测试中需要反射或 Spring 上下文,构造器注入则可直接在测试中 new 被测对象并传入 mock。

  9. 数据库测试使用 @Transactional 或 Testcontainers:本地测试用 @Transactional 回滚保证隔离,CI 环境用 Testcontainers 确保与生产一致。

  10. 定期审查覆盖率报告:覆盖率不是目标而是指标,关注未覆盖的分支与边界条件,而非单纯追求 100% 数字。

  11. Mock 外部系统,Stub 内部依赖:Mockito 适合模拟不可控的外部服务(支付、短信网关),而内部服务可通过 stub 提供返回值,避免过度模拟。

  12. 保持测试运行速度:单元测试应在毫秒级完成。若测试变慢,开发者会跳过执行,失去保护网意义。

  13. 使用 AssertJ 替代 JUnit 断言:流式 API 更易读、错误信息更丰富,且支持复杂对象比较与异常断言。

  14. 避免静态方法模拟:频繁使用 Mockito.mockStatic 意味着架构存在依赖管理问题,优先重构为依赖注入或策略模式。

  15. 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 的性能基准,能够构建既快速又可信的持续交付流水线。测试不是开发的负担,而是设计品质的放大器——写在前面的断言,往往是最可靠的文档与最坚固的防护网。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. Spring Cloud 微服务全栈实践
  2. Spring Security 6.x 与 OAuth2/JWT 安全认证实战
  3. Spring Data JPA 高级指南:关联映射、N+1 与性能优化