Java 测试实践:JUnit、Mockito 与 Testcontainers
本文系统梳理 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
参考资料
- JUnit 5 官方文档
- Mockito 官方文档
- Spring Boot Testing 文档
- Testcontainers 官方文档
- TDD 与测试工程化(TDD 方法论)
评论 (1)