测试
测试是保证代码质量的重要手段。Spring Boot 提供了完善的测试支持,包括单元测试、集成测试、Mock 等多种测试方式。本章将详细介绍 Spring Boot 的测试体系。
测试概述
测试分层
┌─────────────────────────────────────────────────────────────┐
│ 测试金字塔 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ╱╲ │
│ ╱ ╲ 端到端测试(E2E) │
│ ╱────╲ 运行慢、成本高 │
│ ╱ ╲ │
│ ╱────────╲ 集成测试 │
│ ╱ ╲ 测试组件交互 │
│ ╱────────────╲ │
│ ╱ ╲ 单元测试 │
│ ╱────────────────╲ 运行快、数量多 │
│ │
└─────────────────────────────────────────────────────────────┘
测试依赖
Spring Boot 的测试依赖已经包含在 spring-boot-starter-test 中:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
包含的测试框架:
| 框架 | 说明 |
|---|---|
| JUnit 5 | 测试框架 |
| Mockito | Mock 框架 |
| AssertJ | 断言库 |
| Hamcrest | 匹配器库 |
| JSONassert | JSON 断言 |
| JsonPath | JSON 路径查询 |
单元测试
基本单元测试
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class CalculatorTest {
@Test
void shouldAddTwoNumbers() {
// Given
Calculator calculator = new Calculator();
// When
int result = calculator.add(2, 3);
// Then
assertThat(result).isEqualTo(5);
}
@Test
void shouldThrowExceptionWhenDividingByZero() {
Calculator calculator = new Calculator();
assertThatThrownBy(() -> calculator.divide(10, 0))
.isInstanceOf(ArithmeticException.class)
.hasMessageContaining("zero");
}
}
JUnit 5 注解
| 注解 | 说明 |
|---|---|
@Test | 标识测试方法 |
@BeforeEach | 每个测试方法前执行 |
@AfterEach | 每个测试方法后执行 |
@BeforeAll | 所有测试方法前执行一次(static) |
@AfterAll | 所有测试方法后执行一次(static) |
@DisplayName | 测试显示名称 |
@Disabled | 禁用测试 |
@ParameterizedTest | 参数化测试 |
@RepeatedTest | 重复测试 |
测试生命周期
import org.junit.jupiter.api.*;
@DisplayName("用户服务测试")
class UserServiceTest {
@BeforeAll
static void beforeAll() {
System.out.println("所有测试开始前执行一次");
}
@BeforeEach
void setUp() {
System.out.println("每个测试方法前执行");
}
@Test
@DisplayName("创建用户测试")
void shouldCreateUser() {
// 测试代码
}
@Test
@Disabled("暂时跳过")
void skipTest() {
// 跳过的测试
}
@AfterEach
void tearDown() {
System.out.println("每个测试方法后执行");
}
@AfterAll
static void afterAll() {
System.out.println("所有测试结束后执行一次");
}
}
断言
JUnit 5 断言:
import static org.junit.jupiter.api.Assertions.*;
@Test
void testAssertions() {
// 基本断言
assertEquals(5, 2 + 3);
assertTrue(5 > 3);
assertFalse(5 < 3);
assertNull(null);
assertNotNull(new Object());
// 数组断言
assertArrayEquals(new int[]{1, 2, 3}, new int[]{1, 2, 3});
// 异常断言
assertThrows(ArithmeticException.class, () -> {
int result = 10 / 0;
});
// 超时断言
assertTimeout(Duration.ofSeconds(1), () -> {
Thread.sleep(500);
});
// 组合断言
assertAll("用户测试",
() -> assertEquals("张三", user.getName()),
() -> assertEquals(25, user.getAge())
);
}
AssertJ 断言(推荐):
import static org.assertj.core.api.Assertions.*;
@Test
void testAssertJAssertions() {
// 基本断言
assertThat(5).isEqualTo(5);
assertThat(5).isGreaterThan(3);
assertThat(5).isBetween(1, 10);
// 字符串断言
assertThat("Hello World")
.isNotEmpty()
.startsWith("Hello")
.endsWith("World")
.contains("o W");
// 集合断言
assertThat(Arrays.asList(1, 2, 3))
.hasSize(3)
.contains(1, 2)
.doesNotContain(4);
// 对象断言
assertThat(user)
.isNotNull()
.hasFieldOrProperty("name")
.extracting("name", "age")
.containsExactly("张三", 25);
// 异常断言
assertThatThrownBy(() -> service.throwException())
.isInstanceOf(RuntimeException.class)
.hasMessage("错误信息");
// Optional 断言
assertThat(Optional.of("value"))
.isPresent()
.contains("value");
}
参数化测试
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
@ParameterizedTest
@ValueSource(strings = {"hello", "world", "test"})
void testWithStringParameter(String word) {
assertThat(word).isNotEmpty();
}
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"10, 20, 30",
"100, 200, 300"
})
void testWithCsvSource(int a, int b, int expected) {
assertThat(a + b).isEqualTo(expected);
}
@ParameterizedTest
@CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1)
void testWithCsvFileSource(int a, int b, int expected) {
assertThat(a + b).isEqualTo(expected);
}
@ParameterizedTest
@MethodSource("provideNumbers")
void testWithMethodSource(int number, boolean expected) {
assertThat(number > 0).isEqualTo(expected);
}
static Stream<Arguments> provideNumbers() {
return Stream.of(
Arguments.of(1, true),
Arguments.of(-1, false),
Arguments.of(0, false)
);
}
Service 层测试
Mock 对象
使用 Mockito 创建 Mock 对象:
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.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
@Test
void shouldCreateUser() {
// Given
UserDTO dto = new UserDTO("张三", "[email protected]");
User user = new User(1L, "张三", "[email protected]");
when(userRepository.save(any(User.class))).thenReturn(user);
doNothing().when(emailService).sendWelcomeEmail(anyString());
// When
User created = userService.createUser(dto);
// Then
assertThat(created).isNotNull();
assertThat(created.getName()).isEqualTo("张三");
verify(userRepository).save(any(User.class));
verify(emailService).sendWelcomeEmail("zhangsan@example.com);
}
@Test
void shouldThrowExceptionWhenUserExists() {
// Given
UserDTO dto = new UserDTO("张三", "[email protected]");
when(userRepository.existsByEmail("[email protected]")).thenReturn(true);
// When & Then
assertThatThrownBy(() -> userService.createUser(dto))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("已存在");
verify(userRepository, never()).save(any());
}
}
Mockito 常用方法
// 创建 Mock
UserRepository mockRepo = mock(UserRepository.class);
// 设置返回值
when(mockRepo.findById(1L)).thenReturn(Optional.of(user));
when(mockRepo.findById(2L)).thenReturn(Optional.empty());
// 设置抛出异常
when(mockRepo.findById(any())).thenThrow(new RuntimeException());
// 无返回值方法
doNothing().when(emailService).sendEmail(any());
doThrow(new RuntimeException()).when(emailService).sendEmail(any());
// 验证调用
verify(mockRepo).findById(1L); // 验证调用了一次
verify(mockRepo, times(2)).findById(any()); // 验证调用了两次
verify(mockRepo, never()).delete(any()); // 验证从未调用
verify(mockRepo, atLeast(1)).save(any()); // 验证至少调用一次
verify(mockRepo, atMost(3)).save(any()); // 验证最多调用三次
// 参数匹配器
when(mockRepo.findByName(eq("张三"))).thenReturn(user);
when(mockRepo.findByName(anyString())).thenReturn(user);
when(mockRepo.findByName(contains("张"))).thenReturn(user);
when(mockRepo.save(argThat(user -> user.getAge() > 18))).thenReturn(user);
// 验证调用顺序
InOrder inOrder = inOrder(mockRepo, emailService);
inOrder.verify(mockRepo).save(any());
inOrder.verify(emailService).sendEmail(any());
// 清除调用记录
reset(mockRepo);
Spy 对象
Spy 会调用真实方法:
@Test
void testWithSpy() {
// 创建 Spy
List<String> spyList = spy(new ArrayList<>());
// 调用真实方法
spyList.add("one");
spyList.add("two");
assertThat(spyList).hasSize(2);
// 可以 Mock 特定方法
when(spyList.size()).thenReturn(100);
assertThat(spyList.size()).isEqualTo(100);
}
Controller 层测试
使用 MockMvc
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldReturnUserById() throws Exception {
// Given
User user = new User(1L, "张三", "[email protected]");
when(userService.findById(1L)).thenReturn(user);
// When & Then
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("张三"))
.andExpect(jsonPath("$.email").value("[email protected]"));
}
@Test
void shouldCreateUser() throws Exception {
// Given
UserDTO dto = new UserDTO("张三", "[email protected]");
User user = new User(1L, "张三", "[email protected]");
when(userService.create(any(UserDTO.class))).thenReturn(user);
// When & Then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("张三"));
}
@Test
void shouldReturn404WhenUserNotFound() throws Exception {
when(userService.findById(99L)).thenThrow(new BusinessException("用户不存在"));
mockMvc.perform(get("/api/users/99"))
.andExpect(status().isNotFound());
}
}
MockMvc 常用操作
// GET 请求
mockMvc.perform(get("/api/users")
.param("name", "张三")
.param("page", "0")
.header("Authorization", "Bearer token"))
.andExpect(status().isOk());
// POST 请求
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"张三\"}"))
.andExpect(status().isOk());
// PUT 请求
mockMvc.perform(put("/api/users/1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"李四\"}"))
.andExpect(status().isOk());
// DELETE 请求
mockMvc.perform(delete("/api/users/1"))
.andExpect(status().isOk());
// 文件上传
mockMvc.perform(multipart("/api/files")
.file("file", fileContent))
.andExpect(status().isOk());
// 验证响应
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$[0].name").value("张三"))
.andExpect(jsonPath("$", hasSize(2)));
// 验证响应内容
mockMvc.perform(get("/api/users/1"))
.andExpect(content().json("{\"id\":1,\"name\":\"张三\"}"));
@WebMvcTest 切片测试
@WebMvcTest 只加载 Web 层组件:
@WebMvcTest(UserController.class)
// 只加载 UserController,不加载其他 Bean
class UserControllerSliceTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService; // 需要模拟 Service
// 测试方法
}
集成测试
@SpringBootTest
@SpringBootTest
@AutoConfigureMockMvc
class UserIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void shouldCreateAndGetUser() throws Exception {
// 创建用户
String userJson = "{\"name\":\"张三\",\"email\":\"[email protected]\"}";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(userJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").exists());
// 验证数据库
assertThat(userRepository.count()).isEqualTo(1);
// 获取用户
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("张三"));
}
}
使用测试数据库
# src/test/resources/application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
h2:
console:
enabled: true
@SpringBootTest
@ActiveProfiles("test")
class UserIntegrationTest {
// 使用 H2 内存数据库测试
}
使用 Testcontainers
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class UserIntegrationWithTestcontainers {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@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);
}
@Test
void testWithRealDatabase() {
// 使用真实的 MySQL 容器测试
}
}
@DataJpaTest
专门用于 JPA 层测试:
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindUserByEmail() {
// Given
User user = new User();
user.setName("张三");
user.setEmail("[email protected]");
entityManager.persistAndFlush(user);
// When
Optional<User> found = userRepository.findByEmail("[email protected]");
// Then
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("张三");
}
@Test
void shouldNotFindNonExistentUser() {
Optional<User> found = userRepository.findByEmail("[email protected]");
assertThat(found).isEmpty();
}
}
测试配置
测试属性
@SpringBootTest
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:testdb",
"app.feature.enabled=false"
})
class MyTest {
// 使用自定义属性测试
}
测试 Profile
@SpringBootTest
@ActiveProfiles("test")
class MyTest {
// 激活 test profile
}
Mock Bean
@SpringBootTest
class MyTest {
@MockBean
private EmailService emailService; // 替换 Spring 容器中的 Bean
@SpyBean
private UserService userService; // 部分 Mock 的 Bean
}
测试最佳实践
1. 测试命名
// 推荐:使用 should + 预期行为 + 条件
@Test
void shouldThrowExceptionWhenUserNotFound() { }
@Test
void shouldReturnEmptyListWhenNoUsers() { }
// 或使用 Given-When-Then 格式
@Test
void createOrder_WhenProductAvailable_ShouldSucceed() { }
2. 测试结构
@Test
void shouldCreateUser() {
// Given(准备)
UserDTO dto = new UserDTO("张三", "[email protected]");
when(userRepository.save(any())).thenReturn(user);
// When(执行)
User result = userService.create(dto);
// Then(验证)
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo("张三");
verify(emailService).sendWelcomeEmail(any());
}
3. 一个测试一个断言场景
// 不推荐:多个场景混在一起
@Test
void testUser() {
assertThat(user.getName()).isEqualTo("张三");
assertThat(user.getAge()).isEqualTo(25);
assertThat(user.getEmail()).contains("@");
}
// 推荐:分离场景
@Test
void shouldHaveCorrectName() {
assertThat(user.getName()).isEqualTo("张三");
}
@Test
void shouldHaveValidAge() {
assertThat(user.getAge()).isEqualTo(25);
}
@Test
void shouldHaveValidEmail() {
assertThat(user.getEmail()).contains("@");
}
4. 测试隔离
@BeforeEach
void setUp() {
// 重置状态
userRepository.deleteAll();
reset(mockService);
}
@AfterEach
void tearDown() {
// 清理资源
}
5. 避免测试实现细节
// 不推荐:测试内部实现
@Test
void testInternalListSize() {
userService.addUser(user);
assertThat(userService.getInternalList().size()).isEqualTo(1);
}
// 推荐:测试公开行为
@Test
void shouldReturnCreatedUser() {
userService.create(user);
assertThat(userService.findById(1L)).isPresent();
}
运行测试
Maven 命令
# 运行所有测试
mvn test
# 运行指定测试类
mvn test -Dtest=UserTest
# 运行指定测试方法
mvn test -Dtest=UserTest#shouldCreateUser
# 跳过测试
mvn package -DskipTests
测试覆盖率
使用 JaCoCo 生成覆盖率报告:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
小结
本章我们学习了:
- 测试概述:测试分层和依赖
- 单元测试:JUnit 5、AssertJ 断言
- Mock 测试:Mockito 使用
- Controller 测试:MockMvc 使用
- 集成测试:@SpringBootTest、Testcontainers
- 数据层测试:@DataJpaTest
- 最佳实践:命名、结构、隔离
练习
- 为一个 Service 类编写完整的单元测试
- 使用 MockMvc 测试 Controller 的 CRUD 操作
- 使用 Testcontainers 进行数据库集成测试
- 编写参数化测试
- 配置 JaCoCo 并生成覆盖率报告