JUnit 5 最佳实践与测试设计模式
编写高质量、可维护单元测试的规范与模式
测试分层策略
金字塔模型
┌─────────┐
│ E2E │ ← 少量端到端测试
┌┴─────────┴┐
┌┴───────────┴┐
┌┴─────────────┴┐
│ 集成测试 │ ← 中等数量(@SpringBootTest)
┌┴───────────────┴┐
┌┴─────────────────┴┐
│ 单元测试 │ ← 大量单元测试(纯逻辑)
┌┴───────────────────┴┘
JUnit 5 + Mockito分层测试示例
java
// 1. 单元测试:纯逻辑,不依赖 Spring
@ExtendWith(MockitoExtension.class)
class CalculatorUnitTest {
@Test
void add_twoPositiveNumbers() {
assertEquals(5, new Calculator().add(2, 3));
}
}
// 2. 集成测试:数据库、缓存等真实依赖
@DataJpaTest
class UserRepositoryIntegrationTest {
@Test
void saveAndFindByEmail() { /* 使用真实 H2 */ }
}
// 3. 端到端测试:完整 HTTP 请求
@SpringBootTest
@AutoConfigureMockMvc
class UserApiE2ETest {
@Test
void fullCRUD() { /* 真实 HTTP */ }
}测试命名规范
推荐风格
java
// 方法名风格
@Test
void add_positiveNumbers_returnsSum() { }
@Test
void findById_nonExistingUser_throwsException() { }
// @DisplayName 中文命名(最清晰)
@DisplayName("🧮 计算器")
class CalculatorTest {
@Test
@DisplayName("➕ 两个正数相加应返回正确和")
void addTwoPositiveNumbers() { }
@Test
@DisplayName("➗ 除数为零时应抛出 ArithmeticException")
void divideByZeroShouldThrow() { }
}AAA 模式(Arrange-Act-Assert)
标准结构
java
@Test
@DisplayName("创建订单应发送确认邮件")
void createOrderShouldSendConfirmationEmail() {
// Arrange(准备)
OrderRequest request = new OrderRequest("商品A", 2, 100.0);
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
// Act(执行)
OrderResult result = orderService.createOrder(request, 1L);
// Assert(断言)
assertTrue(result.isSuccess());
assertEquals(100L, result.getOrderId());
// Verify(验证)
verify(emailService).sendConfirmation(any());
}简化版(准备很简单时)
java
@Test
void addNumbers() {
Calculator calc = new Calculator(); // Arrange
int result = calc.add(2, 3); // Act
assertEquals(5, result); // Assert
}测试隔离原则
每个测试独立运行
java
@DataJpaTest
@Transactional // 自动回滚,保持测试隔离
class UserRepositoryTest {
@Test
void test1() {
repo.save(new User("A"));
assertEquals(1, repo.count());
}
@Test
void test2() {
// 始终从干净状态开始,不受 test1 影响
assertEquals(0, repo.count());
}
}每个测试只验证一件事
java
// ✅ 好:拆分多个测试
@Test
@DisplayName("创建用户应返回带 ID 的用户对象")
void createUserReturnsUserWithId() {
User user = service.create(request);
assertNotNull(user.getId());
}
@Test
@DisplayName("创建用户应发送欢迎邮件")
void createUserSendsWelcomeEmail() {
service.create(request);
verify(emailService).sendWelcomeEmail(any());
}测试替身(Test Doubles)
| 类型 | 目的 | Mockito 方式 |
|---|---|---|
| Dummy | 填充参数列表 | 不配置返回值 |
| Fake | 简化实现 | new InMemoryUserRepository() |
| Stub | 提供预设响应 | when().thenReturn() |
| Spy | 部分真实行为 | @Spy |
| Mock | 验证交互 | verify() |
示例
java
// Fake - 简化实现
class InMemoryUserRepository implements UserRepository {
private final Map<Long, User> db = new ConcurrentHashMap<>();
}
// Stub - 提供预设响应
@Test
void findUserById_stubResponse() {
when(userRepo.findById(1L)).thenReturn(Optional.of(new User("张三")));
User user = service.findUser(1L);
assertNotNull(user);
}
// Mock - 验证交互
@Test
void createUser_verifyEmailSent() {
service.createUser("test@example.com");
verify(emailService).sendWelcomeEmail("test@example.com");
}异常测试模式
java
@Test
@DisplayName("除数为零应抛出 ArithmeticException")
void divideByZeroThrowsException() {
ArithmeticException ex = assertThrows(
ArithmeticException.class,
() -> calculator.divide(10, 0)
);
assertEquals("Division by zero", ex.getMessage());
}私有方法测试策略
策略 1:不要测试私有方法(推荐)
java
public class OrderService {
public void placeOrder(OrderRequest request) {
validateRequest(request); // private - 不测
Order order = createOrder(request); // private - 不测
sendNotification(order); // public - 测试
}
}策略 2:重构为独立类(可测试)
java
// 重构成独立的验证器类
class OrderValidator {
public ValidationResult validate(OrderRequest request) { }
}
class OrderValidatorTest {
@Test
void emptyProductName_shouldFail() {
ValidationResult result = new OrderValidator()
.validate(new OrderRequest("", 1));
assertFalse(result.isValid());
}
}测试性能优化
并行执行
properties
# junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.config.strategy=dynamic测试超时控制
java
@Test
@Timeout(value = 1, unit = TimeUnit.SECONDS)
void shouldCompleteInOneSecond() { }常见反模式
| 反模式 | 问题 | 解决方案 |
|---|---|---|
Thread.sleep(1000) | 不稳定、慢 | 用 Awaitility 等待条件 |
| 测试依赖数据库状态 | 顺序敏感 | 每个测试自包含 |
assertTrue(list.isEmpty()) | 错误消息差 | assertThat(list).isEmpty() |
| Mock 所有东西 | 不真实 | 用 Fake 替代部分 Mock |
| 过度测试私有方法 | 脆弱 | 测试公开行为 |
| 全局共享状态 | 互相干扰 | 使用 @BeforeEach 重置 |
测试类模板
java
@DisplayName("Xxx 模块测试")
@ExtendWith(MockitoExtension.class)
class XxxServiceTest {
@Mock
private XxxRepository xxxRepository;
@InjectMocks
private XxxService xxxService;
@Nested
@DisplayName("methodName 方法")
class MethodNameTests {
@Test
@DisplayName("场景描述")
void scenario() { }
}
}工具速查
| 工具 | 用途 |
|---|---|
| AssertJ | 流式断言库 |
| Mockito | Mock 框架 |
| TestContainers | 真实数据库测试 |
| REST Assured | REST API 测试 DSL |
| WireMock | HTTP Mock 服务器 |
| ArchUnit | 架构规则测试 |
| JaCoCo | 代码覆盖率 |
| Pitest | 突变测试 |
| Awaitility | 异步测试等待 |
相关页面
- **Java\JUnit5-完全指南** - 返回总览
- **Java\JUnit5-注解体系** - 注解参考
- **Java\JUnit5-断言机制** - 断言使用
- **Java\Mockito-完全指南** - Mock 最佳实践
- **Java\SpringBoot-测试专题** - Spring 集成测试
#标签 #junit #best-practices #testing #design-patterns #tdd #aaa