为什么测试至关重要
测试不仅仅是"确认代码能跑"。对于专业工程师而言,自动化测试至少带来三个层面的价值:
回归保护(Regression Protection)
当你修改某功能时,测试套件能立即发现是否破坏了已有行为,防止"改了 A 坏了 B"的情况在生产环境才发现。
设计指导(Design Guidance)
难以测试的代码往往意味着设计耦合度过高。当你发现某段代码写测试很麻烦时,这是重构信号——代码职责不清晰,依赖关系混乱。
活文档(Living Documentation)
测试代码描述了"系统在各种输入下应有的行为"。好的测试用例比任何文档都更准确、更及时。
测试金字塔
┌───────────────┐
│ E2E 测试 │ 少量,慢,成本高
│ (Playwright) │
╱└───────────────┘╲
╱ ┌─────────────────┐╲
╱ │ 集成测试 │ ╲
╱ │ (@SpringBootTest)│ ╲
╱ └─────────────────┘ ╲
╱ ┌───────────────────────────┐╲
╱ │ 单元测试 │ ╲
╱ │ (JUnit 5 + Mockito) │ ╲
╱ └───────────────────────────┘ ╲
────────────────────────────────────────
多量,快,成本低
建议比例:单元测试 70% + 集成测试 20% + E2E 测试 10%
核心概念名词解释
JUnit 5
Java 最流行的测试框架,由 JUnit Platform + JUnit Jupiter + JUnit Vintage 三部分组成。Spring Boot Test 默认集成 JUnit 5,@Test、@BeforeEach、@AfterEach、@ParameterizedTest 等注解来自 JUnit Jupiter。
Mockito
Java Mock 框架,用于创建测试替身(Test Double)。可以 mock 一个接口或类,预设其方法的返回值,从而隔离被测单元的外部依赖(如数据库、HTTP 调用)。
@Mock 与 @InjectMocks
@Mock 创建 mock 对象;@InjectMocks 创建被测类的实例并自动将 @Mock 对象注入其中(通过构造器或字段)。配合 @ExtendWith(MockitoExtension.class) 使用。
@SpringBootTest
启动完整的 Spring ApplicationContext 进行集成测试,加载所有 Bean、配置、数据库连接等。测试更接近真实环境但启动较慢。可通过 webEnvironment 参数控制是否启动真实 HTTP 服务器。
MockMvc
Spring MVC 测试框架,可以模拟 HTTP 请求并验证响应,无需启动真实服务器。支持链式 API:perform(get("/users/1")).andExpect(status().isOk())。
TestContainers
通过 Docker 启动真实的数据库/消息队列容器来进行集成测试。相比 H2 内存数据库,TestContainers 使用与生产环境相同的数据库,避免方言差异导致的问题。
单元测试:UserService
单元测试的目标是隔离测试一个类的逻辑,不涉及任何真实的外部依赖(数据库、网络等)。
@ExtendWith(MockitoExtension.class) // 激活 Mockito 注解处理
class UserServiceTest {
@Mock
private UserRepository userRepository; // Mock 依赖
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private UserServiceImpl userService; // 被测类,依赖自动注入
@Test
@DisplayName("根据 ID 查询用户 — 用户存在时返回 DTO")
void findById_UserExists_ReturnsDTO() {
// Given(准备数据和 mock 行为)
User user = User.builder().id(1L).username("alice").email("alice@example.com").build();
Mockito.when(userRepository.findById(1L)).thenReturn(Optional.of(user));
// When(执行被测方法)
UserResponseDTO result = userService.findById(1L);
// Then(断言结果)
assertThat(result).isNotNull();
assertThat(result.getUsername()).isEqualTo("alice");
assertThat(result.getEmail()).isEqualTo("alice@example.com");
verify(userRepository).findById(1L); // 验证 mock 被正确调用
}
@Test
@DisplayName("根据 ID 查询用户 — 用户不存在时抛出异常")
void findById_UserNotFound_ThrowsException() {
// Given
Mockito.when(userRepository.findById(99L)).thenReturn(Optional.empty());
// When & Then(断言异常)
assertThatThrownBy(() -> userService.findById(99L))
.isInstanceOf(EntityNotFoundException.class)
.hasMessageContaining("99");
}
@Test
@DisplayName("创建用户 — 邮箱已存在时抛出业务异常")
void create_DuplicateEmail_ThrowsBusinessException() {
// Given
UserCreateDTO dto = new UserCreateDTO("bob", "existing@example.com", "Password1");
Mockito.when(userRepository.existsByEmail("existing@example.com")).thenReturn(true);
// When & Then
assertThatThrownBy(() -> userService.create(dto))
.isInstanceOf(BusinessException.class);
verify(userRepository, never()).save(any()); // 确保未调用 save
}
}
集成测试:MockMvc 测试 HTTP 接口
@SpringBootTest
@AutoConfigureMockMvc // 自动配置 MockMvc
@Transactional // 每个测试后自动回滚,不污染数据
class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper; // JSON 序列化/反序列化
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("POST /users — 创建用户成功返回 201")
void createUser_ValidInput_Returns201() throws Exception {
UserCreateDTO dto = new UserCreateDTO("testuser", "test@example.com", "Password1");
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.username").value("testuser"))
.andExpect(jsonPath("$.data.email").value("test@example.com"))
.andExpect(jsonPath("$.data.id").exists());
}
@Test
@DisplayName("POST /users — 邮箱格式错误返回 400")
void createUser_InvalidEmail_Returns400() throws Exception {
UserCreateDTO dto = new UserCreateDTO("testuser", "not-an-email", "Password1");
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
@Test
@DisplayName("GET /users/{id} — 需要认证,未登录返回 401")
void getUser_Unauthenticated_Returns401() throws Exception {
mockMvc.perform(get("/users/1"))
.andExpect(status().isUnauthorized());
}
}
TestContainers:使用真实 PostgreSQL 测试
@SpringBootTest
@Testcontainers // 激活 TestContainers 注解
class UserRepositoryTest {
// 声明 PostgreSQL 容器(自动启动 Docker 容器)
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
// 将容器的连接信息动态注入 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
void findByEmail_ReturnsCorrectUser() {
User user = User.builder()
.username("alice")
.email("alice@test.com")
.passwordHash("hash")
.build();
userRepository.save(user);
Optional<User> found = userRepository.findByEmail("alice@test.com");
assertThat(found).isPresent();
assertThat(found.get().getUsername()).isEqualTo("alice");
}
}
测试覆盖率:JaCoCo
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<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>
<limits>
<!-- 强制 Service 层覆盖率 ≥ 80% -->
<limit>
<counter>LINE</counter>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Tip
集成测试加 @Transactional 注解可以在测试结束后自动回滚数据,不污染测试数据库。注意:如果测试方法调用了异步操作(@Async)或在新事务中执行(REQUIRES_NEW),这些操作不会被回滚。
Info
对于测试命名,推荐遵循"方法名_场景_预期结果"的格式,如
findById_UserNotFound_ThrowsException。配合 @DisplayName 可以写中文描述,让测试报告更易读。