目录
正在加载目录…
专栏文章
专栏文章
Java 专栏
1. Java:跨平台语言与生态全景 2. Java 并发:JMM、锁与线程池原理 3. JVM:类加载、内存、GC 与性能诊断 4. Java 性能调优:CPU、内存、锁与 IO 排查 5. Java 新特性速查:从 Lambda 到虚拟线程 6. Java 业务开发:高频陷阱与 Code Review 清单 7. Java 核心知识:集合、并发与 JVM 面试要点 8. AQS 原理:同步队列与加锁解锁全流程 9. JVM 与 Linux 内存:分区、分配与 GC 边界 10. AtomicLong 与 LongAdder:并发计数器选型 11. Java 引用与内存泄漏:六类场景与修复 12. Java 测试实践:JUnit、Mockito 与 Testcontainers 13. Java 并发入门:线程、锁与线程池全景

Java 测试实践:JUnit、Mockito 与 Testcontainers

发布于 2026-06-17 14:40 · 最后编辑于 2026-07-31 15:51 · 字数 1,144 👁 139 次阅读

本文系统梳理 Java 测试生态:JUnit 5 核心特性、Mockito 深度用法、Spring Boot Test 分层测试(@WebMvcTest / @DataJpaTest)、Testcontainers 真实容器集成测试,以及 AssertJ 与 JaCoCo 覆盖率配置。

目录

章节说明
测试分层策略单元 / 切片 / 集成测试的定位
JUnit 5 核心特性生命周期、参数化、条件测试、扩展
Mockito 深度Mock/Spy/Captor、stubbing 与 verify
AssertJ 断言流式断言 API 速查
Spring Boot Test@SpringBootTest、@WebMvcTest、@DataJpaTest
Testcontainers真实 DB / Redis 容器集成测试
测试覆盖率 JaCoCo生成报告、设置覆盖率门禁

测试分层策略

graph TD
    U["单元测试(Unit Test)<br/>隔离依赖,速度极快<br/>@ExtendWith(MockitoExtension)"]
    S["切片测试(Slice Test)<br/>只加载部分 Spring Context<br/>@WebMvcTest / @DataJpaTest"]
    I["集成测试(Integration Test)<br/>完整 Spring Context + 真实 DB<br/>@SpringBootTest + Testcontainers"]
    U --> S --> I
    style U fill:#cfc,stroke:#060
    style S fill:#fff3cd,stroke:#856404
    style I fill:#f8d7da,stroke:#721c24
层次速度真实度数量比例适用
单元测试毫秒级低(Mock)70%业务逻辑、纯计算
切片测试秒级20%Controller 层、Repository 层
集成测试十秒级10%关键业务流程端到端验证

JUnit 5 核心特性

生命周期注解

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class OrderServiceTest {

    @BeforeAll
    static void setUpAll() { /* 整个测试类执行前,一次 */ }

    @BeforeEach
    void setUp() { /* 每个测试方法执行前 */ }

    @AfterEach
    void tearDown() { /* 每个测试方法执行后 */ }

    @AfterAll
    static void tearDownAll() { /* 整个测试类执行后,一次 */ }

    @Test
    @Order(1)
    @DisplayName("创建订单:正常场景")
    void createOrder_success() { ... }

    @Test
    @Order(2)
    @Disabled("待实现")
    void cancelOrder() { ... }
}

参数化测试

// @ValueSource:基本类型数组
@ParameterizedTest
@ValueSource(strings = {"", "  ", "\t"})
void isBlank_shouldReturnTrue(String input) {
    assertThat(StringUtils.isBlank(input)).isTrue();
}

// @CsvSource:多参数
@ParameterizedTest
@CsvSource({
    "100.00, 0.1, 90.00",   // amount, discount, expected
    "200.00, 0.2, 160.00",
    "50.00,  0.0, 50.00"
})
void applyDiscount(BigDecimal amount, double discount, BigDecimal expected) {
    assertThat(priceService.applyDiscount(amount, discount)).isEqualByComparingTo(expected);
}

// @MethodSource:复杂对象
@ParameterizedTest
@MethodSource("invalidOrderRequests")
void createOrder_invalidInput_shouldFail(CreateOrderRequest req, String expectedMsg) {
    assertThatThrownBy(() -> orderService.create(req))
        .isInstanceOf(ValidationException.class)
        .hasMessageContaining(expectedMsg);
}

static Stream<Arguments> invalidOrderRequests() {
    return Stream.of(
        Arguments.of(new CreateOrderRequest(null, 1), "userId"),
        Arguments.of(new CreateOrderRequest(1L, 0), "quantity"),
        Arguments.of(new CreateOrderRequest(1L, -1), "quantity")
    );
}

// @EnumSource:枚举
@ParameterizedTest
@EnumSource(value = OrderStatus.class, names = {"CANCELLED", "REFUNDED"})
void isTerminal_shouldReturnTrueForTerminalStatuses(OrderStatus status) {
    assertThat(status.isTerminal()).isTrue();
}

条件测试

@Test
@EnabledOnOs(OS.LINUX)
void linuxOnlyTest() { ... }

@Test
@EnabledIfSystemProperty(named = "env", matches = "ci")
void ciOnlyTest() { ... }

@Test
@EnabledIfEnvironmentVariable(named = "FEATURE_FLAG", matches = "true")
void featureFlagTest() { ... }

异常测试

@Test
void getUser_notFound_shouldThrow() {
    // 精确断言异常类型和消息
    assertThatThrownBy(() -> userService.getById(999L))
        .isInstanceOf(UserNotFoundException.class)
        .hasMessage("用户不存在: 999")
        .hasNoCause();

    // 或用 assertThrows
    UserNotFoundException ex = assertThrows(
        UserNotFoundException.class,
        () -> userService.getById(999L)
    );
    assertThat(ex.getUserId()).isEqualTo(999L);
}

Mockito 深度

Mock vs Spy

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    UserRepository userRepository;   // 全量 Mock,所有方法默认返回空值/0/false

    @Spy
    UserValidator userValidator = new UserValidator();  // 真实对象 + 部分 stub

    @InjectMocks
    UserService userService;         // 自动注入上方 Mock/Spy
}
特性@Mock@Spy
方法默认行为返回类型默认值(null/0/false)调用真实方法
适用场景依赖的外部接口/Repository部分 stub 的真实对象
stub 未配置方法返回默认值,不报错执行真实逻辑

Stubbing(行为配置)

// 返回值
when(userRepository.findById(1L)).thenReturn(Optional.of(testUser));
when(userRepository.findById(anyLong())).thenReturn(Optional.empty());

// 链式返回(第一次…第二次…)
when(userRepository.count()).thenReturn(0L, 1L, 2L);

// 抛出异常
when(userRepository.save(any())).thenThrow(new DataAccessException("DB error") {});

// void 方法(doNothing/doThrow 语法)
doNothing().when(emailService).sendWelcomeEmail(anyString());
doThrow(new RuntimeException("smtp fail")).when(emailService).sendWelcomeEmail("bad@email");

// Spy 部分 stub(避免调用真实方法)
doReturn(true).when(userValidator).validate(any());  // 用 doReturn 而非 when().thenReturn()

⚠️ Spy 的坑:对 Spy 使用 when(spy.method()).thenReturn(...) 会先执行一次真实方法再 stub,应改用 doReturn(...).when(spy).method()

参数匹配器

// 精确值
when(repo.findById(1L)).thenReturn(...);

// 任意值
when(repo.save(any(User.class))).thenReturn(savedUser);
when(repo.findByStatus(anyString())).thenReturn(List.of());

// 自定义匹配(argThat)
when(repo.save(argThat(u -> u.getEmail().endsWith("@example.com"))))
    .thenReturn(savedUser);

// 捕获参数(ArgumentCaptor)
@Captor ArgumentCaptor<User> userCaptor;

verify(userRepository).save(userCaptor.capture());
User saved = userCaptor.getValue();
assertThat(saved.getUsername()).isEqualTo("alice");
assertThat(saved.getStatus()).isEqualTo(UserStatus.ACTIVE);

Verify(行为验证)

// 验证方法被调用
verify(userRepository).findById(1L);

// 验证调用次数
verify(emailService, times(1)).sendWelcomeEmail(any());
verify(auditLog, never()).logFailure(any());
verify(cache, atLeastOnce()).put(anyLong(), any());

// 验证调用顺序
InOrder inOrder = inOrder(userRepository, emailService);
inOrder.verify(userRepository).save(any());
inOrder.verify(emailService).sendWelcomeEmail(any());

// 验证无更多交互
verifyNoMoreInteractions(userRepository);

AssertJ 断言

// 字符串
assertThat(name).isNotBlank()
                .startsWith("Alice")
                .containsIgnoringCase("ALICE")
                .hasSize(5);

// 数字
assertThat(price).isGreaterThan(BigDecimal.ZERO)
                 .isLessThanOrEqualTo(new BigDecimal("9999.99"))
                 .isEqualByComparingTo("100.00");  // BigDecimal 用这个,不用 isEqualTo

// 集合
assertThat(users).isNotEmpty()
                 .hasSize(3)
                 .extracting(User::getUsername)    // 提取字段后断言
                 .containsExactlyInAnyOrder("alice", "bob", "carol");

assertThat(users).filteredOn(u -> u.getStatus() == ACTIVE)
                 .hasSize(2);

// Optional
assertThat(result).isPresent()
                  .hasValueSatisfying(u -> assertThat(u.getId()).isPositive());

// 异常(推荐 assertThatThrownBy)
assertThatThrownBy(() -> service.doSomething())
    .isInstanceOf(RuntimeException.class)
    .hasMessageContaining("expected error");

// 自定义对象
assertThat(order).isNotNull()
                 .hasFieldOrPropertyWithValue("status", "PENDING")
                 .satisfies(o -> {
                     assertThat(o.getAmount()).isPositive();
                     assertThat(o.getUserId()).isNotNull();
                 });

Spring Boot Test

@WebMvcTest(Controller 切片)

只加载 Controller 层相关 Bean,速度快:

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean                       // 替换 Spring Context 中的 Bean
    UserService userService;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    void getUser_shouldReturn200() throws Exception {
        UserDTO dto = new UserDTO(1L, "alice", "alice@example.com");
        when(userService.getById(1L)).thenReturn(dto);

        mockMvc.perform(get("/api/users/1")
                .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.username").value("alice"));
    }

    @Test
    void createUser_invalidBody_shouldReturn400() throws Exception {
        CreateUserRequest req = new CreateUserRequest("", "bad-email");

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(req)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors").isArray());
    }
}

@DataJpaTest(Repository 切片)

只加载 JPA 相关 Bean,使用嵌入式数据库(H2):

@DataJpaTest
class UserRepositoryTest {

    @Autowired
    UserRepository userRepository;

    @Autowired
    TestEntityManager em;

    @Test
    void findByEmail_shouldReturnUser() {
        // 准备数据
        User user = em.persistAndFlush(new User("alice", "alice@test.com"));

        Optional<User> result = userRepository.findByEmail("alice@test.com");

        assertThat(result).isPresent()
                          .hasValueSatisfying(u -> assertThat(u.getId()).isEqualTo(user.getId()));
    }
}

@DataJpaTest 配合真实 DB:加 @AutoConfigureTestDatabase(replace = NONE) + Testcontainers。

@SpringBootTest(完整集成测试)

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class OrderIntegrationTest {

    @Autowired
    TestRestTemplate restTemplate;

    @Test
    void createAndFetchOrder_fullFlow() {
        // 创建订单
        CreateOrderRequest req = new CreateOrderRequest(1L, 2);
        ResponseEntity<OrderDTO> created = restTemplate.postForEntity(
            "/api/orders", req, OrderDTO.class);
        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);

        Long orderId = created.getBody().getId();

        // 查询订单
        ResponseEntity<OrderDTO> fetched = restTemplate.getForEntity(
            "/api/orders/" + orderId, OrderDTO.class);
        assertThat(fetched.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(fetched.getBody().getStatus()).isEqualTo("PENDING");
    }
}

Testcontainers

Testcontainers 在测试时启动真实 Docker 容器,消除测试与生产环境的数据库差异:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <scope>test</scope>
</dependency>

推荐:@ServiceConnection(Spring Boot 3.1+)

@SpringBootTest
@Testcontainers
class UserRepositoryIntegrationTest {

    @Container
    @ServiceConnection                        // 自动注入数据源配置,无需手动设置
    static MySQLContainer<?> mysql =
        new MySQLContainer<>("mysql:8.0")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @Container
    @ServiceConnection
    static RedisContainer redis = new RedisContainer("redis:7");

    @Autowired
    UserRepository userRepository;

    @Test
    void save_andFind_withRealMySQL() {
        User user = userRepository.save(new User("alice", "alice@test.com"));
        assertThat(user.getId()).isPositive();

        Optional<User> found = userRepository.findByEmail("alice@test.com");
        assertThat(found).isPresent();
    }
}

共享容器(提升性能)

// 所有测试类共用同一个容器实例(测试套件级别)
@Testcontainers
public abstract class AbstractIntegrationTest {

    @Container
    static final MySQLContainer<?> MYSQL =
        new MySQLContainer<>("mysql:8.0")
            .withReuse(true);    // 允许跨测试类复用容器

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

// 具体测试类继承
class UserRepositoryTest extends AbstractIntegrationTest { ... }
class OrderRepositoryTest extends AbstractIntegrationTest { ... }

测试覆盖率 JaCoCo

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <executions>
        <!-- 准备 agent -->
        <execution>
            <id>prepare-agent</id>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <!-- 生成报告(target/site/jacoco/index.html)-->
        <execution>
            <id>report</id>
            <phase>verify</phase>
            <goals><goal>report</goal></goals>
        </execution>
        <!-- 覆盖率门禁:低于阈值构建失败 -->
        <execution>
            <id>check</id>
            <goals><goal>check</goal></goals>
            <configuration>
                <rules>
                    <rule>
                        <element>PACKAGE</element>
                        <limits>
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum>   <!-- 80% 行覆盖率 -->
                            </limit>
                        </limits>
                    </rule>
                </rules>
                <excludes>
                    <!-- 排除不需要覆盖的类 -->
                    <exclude>**/config/**</exclude>
                    <exclude>**/dto/**</exclude>
                    <exclude>**/*Application.class</exclude>
                </excludes>
            </configuration>
        </execution>
    </executions>
</plugin>
# 生成报告
mvn clean test jacoco:report

# 查看报告
open target/site/jacoco/index.html

参考资料

← 返回列表

评论 (1)

admin 2026-07-03 14:23
👍
登录注册 后才能发表评论