Spring Boot 测试
测试是保证代码质量的关键环节。Spring Boot 提供了完善的测试支持,让编写测试变得简单高效。本章将详细介绍 Spring Boot 的测试体系。
本文档基于 Spring Boot 3.4 测试文档 编写,涵盖了最新的测试特性和最佳实践。
测试框架概述
Spring Boot 测试框架建立在以下技术之上:
- JUnit 5:现代 Java 测试框架,支持参数化测试、嵌套测试等特性
- Spring Test:Spring 框架的测试支持,提供上下文管理和依赖注入
- Mockito:Mock 对象框架,用于模拟依赖组件
- AssertJ:流式断言库,提供丰富的断言方法
- Hamcrest:匹配器库,用于编写灵活的断言
- JSONassert:JSON 比较工具
- JsonPath:JSON 路径查询
测试分层
测试金字塔展示了不同类型测试的比例关系:
┌─────────────────────────────────────────────────────────────┐
│ 测试金字塔 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ╱╲ │
│ ╱ ╲ 端到端测试(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("[email protected]");
}
@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\":\"张三\"}"));
使用 MockMvcTester(Spring Boot 3.4+)
Spring Boot 3.4 引入了 MockMvcTester,这是一个基于 AssertJ 的 MockMvc 替代方案,提供了更流畅的测试体验。
为什么使用 MockMvcTester?
传统的 MockMvc 使用 Hamcrest 匹配器,而 MockMvcTester 使用 AssertJ,具有以下优势:
- 更流畅的 API,代码可读性更高
- 更好的 IDE 自动补全支持
- 与其他 AssertJ 断言风格一致
- 更详细的错误信息
基本使用:
import org.springframework.test.web.servlet.assertj.MockMvcTester;
@WebMvcTest(UserController.class)
class UserControllerMockMvcTesterTest {
@Autowired
private MockMvcTester mvc; // 注意:使用 MockMvcTester 而非 MockMvc
@MockBean
private UserService userService;
@Test
void shouldReturnUserById() {
// Given
User user = new User(1L, "张三", "[email protected]");
when(userService.findById(1L)).thenReturn(user);
// When & Then - 使用 AssertJ 风格断言
assertThat(mvc.get().uri("/api/users/1"))
.hasStatusOk()
.hasJsonPath("$.id", equalTo(1))
.hasJsonPath("$.name", equalTo("张三"))
.hasJsonPath("$.email", equalTo("[email protected]"));
}
@Test
void shouldCreateUser() {
// Given
UserDTO dto = new UserDTO("张三", "[email protected]");
User user = new User(1L, "张三", "[email protected]");
when(userService.create(any(UserDTO.class))).thenReturn(user);
// When & Then
assertThat(mvc.post().uri("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"张三\",\"email\":\"[email protected]\"}"))
.hasStatusOk()
.hasJsonPath("$.name", equalTo("张三"));
}
@Test
void shouldReturn404WhenUserNotFound() {
when(userService.findById(99L)).thenThrow(new BusinessException("用户不存在"));
assertThat(mvc.get().uri("/api/users/99"))
.hasStatus(HttpStatus.NOT_FOUND);
}
}
MockMvcTester 常用操作:
// GET 请求带参数和请求头
assertThat(mvc.get().uri("/api/users")
.param("name", "张三")
.param("page", "0")
.header("Authorization", "Bearer token"))
.hasStatusOk();
// POST 请求
assertThat(mvc.post().uri("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"张三\"}"))
.hasStatusOk();
// PUT 请求
assertThat(mvc.put().uri("/api/users/1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"李四\"}"))
.hasStatusOk();
// DELETE 请求
assertThat(mvc.delete().uri("/api/users/1"))
.hasStatusOk();
// 文件上传
assertThat(mvc.multipart().uri("/api/files")
.file("file", fileContent))
.hasStatusOk();
// 验证响应内容
assertThat(mvc.get().uri("/api/users"))
.hasStatusOk()
.hasContentType(MediaType.APPLICATION_JSON)
.hasJsonPath("$[0].name", equalTo("张三"))
.hasJsonPath("$.size()", equalTo(2));
// 验证响应体文本
assertThat(mvc.get().uri("/api/users/1"))
.hasBodyTextEqualTo("{\"id\":1,\"name\":\"张三\"}");
// 提取响应对象进行进一步验证
MvcTestResult result = mvc.get().uri("/api/users/1").exchange();
assertThat(result.getResponse().getContentAsString()).contains("张三");
MockMvc vs MockMvcTester 对比:
// 传统 MockMvc 方式(Hamcrest)
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("张三"));
// MockMvcTester 方式(AssertJ)
assertThat(mvc.get().uri("/api/users/1"))
.hasStatusOk()
.hasJsonPath("$.name", equalTo("张三"));
解释:MockMvcTester 的 API 更加直观,使用 assertThat() 包装整个请求,然后链式调用断言方法。方法名以 has 开头,语义更加清晰。
@WebMvcTest 切片测试
@WebMvcTest 只加载 Web 层组件,不会加载完整的 ApplicationContext,因此测试速度更快:
@WebMvcTest(UserController.class)
// 只加载 UserController,不加载其他 Bean
class UserControllerSliceTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService; // 需要模拟 Service
// 测试方法
}
切片测试注解:
| 注解 | 说明 |
|---|---|
@WebMvcTest | 测试 Spring MVC 控制器 |
@WebFluxTest | 测试 Spring WebFlux 控制器 |
@DataJpaTest | 测试 JPA 组件 |
@DataRedisTest | 测试 Redis 组件 |
@DataMongoTest | 测试 MongoDB 组件 |
@JdbcTest | 测试 JDBC 组件 |
@JsonTest | 测试 JSON 序列化 |
使用 RestTestClient(Spring Boot 3.4+)
Spring Boot 3.4 引入了 RestTestClient,这是一个现代化的 HTTP 客户端测试工具。它可以用于两种场景:
- 模拟环境:配合
@AutoConfigureMockMvc使用,不需要启动真实服务器 - 真实服务器:配合
@SpringBootTest(webEnvironment = RANDOM_PORT)使用
基本使用:
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.test.web.servlet.client.RestTestClient;
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestTestClient
class UserRestControllerTest {
@Autowired
RestTestClient restClient;
@Test
void getUserById() {
restClient.get().uri("/api/users/1")
.exchange()
.expectStatus().isOk()
.expectBody(User.class)
.isEqualTo(new User(1L, "张三", "[email protected]"));
}
@Test
void createUser() {
UserDTO dto = new UserDTO("张三", "[email protected]");
restClient.post().uri("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(dto)
.exchange()
.expectStatus().isCreated()
.expectBody()
.jsonPath("$.name").isEqualTo("张三");
}
}
使用 AssertJ 断言:
import org.springframework.test.web.servlet.client.assertj.RestTestClientResponse;
@Test
void createUserWithAssertJ() {
UserDTO dto = new UserDTO("张三", "[email protected]");
var spec = restClient.post().uri("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(dto)
.exchange();
RestTestClientResponse response = RestTestClientResponse.from(spec);
assertThat(response)
.hasStatusOk()
.bodyJson()
.convertTo(User.class)
.satisfies(user -> {
assertThat(user.getName()).isEqualTo("张三");
assertThat(user.getEmail()).isEqualTo("[email protected]");
});
}
使用 WebTestClient 测试 WebFlux
如果你的应用使用 Spring WebFlux,可以使用 WebTestClient 进行测试:
import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient;
import org.springframework.test.web.reactive.server.WebTestClient;
@SpringBootTest
@AutoConfigureWebTestClient
class UserWebFluxTest {
@Autowired
WebTestClient webClient;
@Test
void getUserById() {
webClient.get().uri("/api/users/1")
.exchange()
.expectStatus().isOk()
.expectBody(User.class)
.isEqualTo(new User(1L, "张三", "[email protected]"));
}
}
WebTestClient vs MockMvc vs RestTestClient:
| 特性 | MockMvc | RestTestClient | WebTestClient |
|---|---|---|---|
| 适用场景 | Spring MVC | Spring MVC | Spring WebFlux |
| 断言风格 | Hamcrest | 自定义 + AssertJ | 自定义 + AssertJ |
| 运行环境 | 模拟环境 | 模拟或真实服务器 | 模拟或真实服务器 |
| Spring Boot 版本 | 所有版本 | 3.4+ | 2.0+ |
集成测试
@SpringBootTest 详解
@SpringBootTest 用于需要加载完整 ApplicationContext 的集成测试。它会通过 SpringApplication 创建上下文,启用 Spring Boot 的所有特性。
webEnvironment 属性:
| 值 | 说明 | 使用场景 |
|---|---|---|
MOCK(默认) | 加载 WebApplicationContext,提供模拟环境,不启动服务器 | 配合 MockMvc/RestTestClient 使用 |
RANDOM_PORT | 启动嵌入式服务器,监听随机端口 | 测试真实 HTTP 交互 |
DEFINED_PORT | 启动嵌入式服务器,监听配置文件指定的端口 | 需要固定端口的场景 |
NONE | 加载 ApplicationContext,但不提供任何 Web 环境 | 非 Web 应用测试 |
使用随机端口测试:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class UserIntegrationTest {
@LocalServerPort
private int port;
@Autowired
private RestTestClient restClient;
@Test
void shouldCreateAndGetUser() {
// 创建用户
UserDTO dto = new UserDTO("张三", "[email protected]");
restClient.post().uri("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(dto)
.exchange()
.expectStatus().isCreated();
// 获取用户
restClient.get().uri("/api/users/1")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.name").isEqualTo("张三");
}
}
使用 TestRestTemplate:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
class UserRestTemplateTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldCreateUser() {
UserDTO dto = new UserDTO("张三", "[email protected]");
ResponseEntity<User> response = restTemplate.postForEntity(
"/api/users", dto, User.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().getName()).isEqualTo("张三");
}
}
模拟环境测试
默认情况下,@SpringBootTest 不会启动服务器,而是创建模拟环境。这种方式更快,适合测试 Web 层:
@SpringBootTest
@AutoConfigureMockMvc
class UserMockMvcIntegrationTest {
@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("张三"));
}
}
测试配置发现
Spring Boot 会自动搜索主配置类(标注了 @SpringBootApplication 或 @SpringBootConfiguration 的类)。搜索算法从测试类所在包开始向上查找。
如果需要自定义配置,可以使用嵌套的 @TestConfiguration 类:
@SpringBootTest
class MyTest {
@TestConfiguration
static class TestConfig {
@Bean
public TestService testService() {
return new TestService();
}
}
@Autowired
private TestService testService;
}
注意:@TestConfiguration 类不会通过组件扫描自动检测,需要使用 @Import 显式导入(顶级类)或作为嵌套类(自动检测)。
使用 main 方法初始化上下文
Spring Boot 3.x 支持 useMainMethod 属性,让测试使用应用的 main 方法创建上下文:
@SpringBootTest(useMainMethod = SpringBootTest.UseMainMethod.ALWAYS)
class MyApplicationTests {
// 会调用 MyApplication.main() 创建上下文
// 这样 main 方法中的自定义配置也会生效
}
何时使用:
- 当
main方法中包含重要的初始化逻辑时 - 当
main方法设置了额外的 Profile 或属性时
使用测试数据库
# 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 层测试,只加载与 JPA 相关的组件:
特点:
- 自动配置内存数据库(H2、HSQLDB 或 Derby)
- 配置 Hibernate、实体管理器和数据源
- 扫描
@Entity类 - 每个测试默认是事务性的,测试结束后回滚
@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();
}
}
使用真实数据库:
如果需要使用真实数据库而非内存数据库:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryRealDbTest {
@Autowired
private UserRepository userRepository;
}
自定义 JPA 属性:
@DataJpaTest(properties = {
"spring.jpa.show-sql=true",
"spring.jpa.properties.hibernate.format_sql=true"
})
class UserRepositoryTest {
// 启用 SQL 日志输出
}
@JdbcTest
用于测试纯 JDBC 操作,不涉及 JPA:
@JdbcTest
class UserDaoTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldQueryUserCount() {
jdbcTemplate.update("INSERT INTO users (name, email) VALUES (?, ?)",
"张三", "[email protected]");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users", Integer.class);
assertThat(count).isEqualTo(1);
}
}
@JsonTest
用于测试 JSON 序列化和反序列化:
@JsonTest
class UserJsonTest {
@Autowired
private JacksonTester<User> jsonTester;
@Test
void shouldSerializeUser() throws IOException {
User user = new User(1L, "张三", "[email protected]");
JsonContent<User> result = jsonTester.write(user);
assertThat(result).hasJsonPathNumberValue("$.id", 1);
assertThat(result).hasJsonPathStringValue("$.name", "张三");
assertThat(result).doesNotHaveJsonPath("$.password");
}
@Test
void shouldDeserializeUser() throws IOException {
String content = "{\"id\":1,\"name\":\"张三\",\"email\":\"[email protected]\"}";
User user = jsonTester.parseObject(content);
assertThat(user.getId()).isEqualTo(1L);
assertThat(user.getName()).isEqualTo("张三");
}
}
测试配置
测试属性
Spring Boot 提供了多种方式来配置测试属性:
方式一:@TestPropertySource 注解
@SpringBootTest
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:testdb",
"app.feature.enabled=false"
})
class MyTest {
// 使用自定义属性测试
}
方式二:@SpringBootTest 的 properties 属性
@SpringBootTest(properties = {
"spring.main.web-application-type=reactive",
"app.cache.enabled=false"
})
class MyTest {
// 简洁的方式设置测试属性
}
方式三:测试专用配置文件
在 src/test/resources/ 目录下创建 application-test.yml:
# 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
logging:
level:
root: warn
org.springframework.test: debug
然后在测试中激活:
@SpringBootTest
@ActiveProfiles("test")
class MyTest {
// 激活 test profile
}
测试 Profile
使用 @ActiveProfiles 激活特定的测试配置:
@SpringBootTest
@ActiveProfiles("test")
class MyTest {
// 激活 test profile
// 会加载 application-test.yml 和 application-test.properties
}
Mock Bean
在集成测试中,可以使用 @MockBean 和 @SpyBean 来模拟或监视 Spring 容器中的 Bean:
@MockBean:完全模拟一个 Bean
@SpringBootTest
class MyTest {
@MockBean
private EmailService emailService; // 替换 Spring 容器中的 Bean
@Test
void testUserRegistration() {
// 配置 Mock 行为
when(emailService.sendEmail(any())).thenReturn(true);
// 测试逻辑...
// 验证 Mock 调用
verify(emailService).sendEmail(any());
}
}
@SpyBean:部分模拟,保留原始行为
@SpringBootTest
class MyTest {
@SpyBean
private UserService userService; // 部分 Mock 的 Bean
@Test
void testWithSpy() {
// 只模拟特定方法,其他方法调用真实实现
doReturn(Optional.empty()).when(userService).findById(99L);
// 其他方法仍使用真实实现
userService.createUser(dto); // 调用真实方法
}
}
注入测试参数
JUnit 5 支持在测试方法参数中注入 Spring Bean:
@SpringBootTest
class MyParameterTest {
@Test
void testWithParameterInjection(
@Autowired UserService userService,
@Autowired UserRepository userRepository
) {
// 直接在参数中注入需要的 Bean
assertThat(userService).isNotNull();
}
}
应用参数测试
如果应用需要命令行参数,可以使用 args 属性:
@SpringBootTest(args = "--app.test=one")
class MyApplicationArgumentTests {
@Test
void applicationArgumentsPopulated(@Autowired ApplicationArguments args) {
assertThat(args.getOptionNames()).contains("app.test");
assertThat(args.getOptionValues("app.test")).containsOnly("one");
}
}
测试最佳实践
1. 测试命名
良好的测试命名能让其他人快速理解测试的目的:
// 推荐:使用 should + 预期行为 + 条件
@Test
void shouldThrowExceptionWhenUserNotFound() { }
@Test
void shouldReturnEmptyListWhenNoUsers() { }
// 或使用 Given-When-Then 格式
@Test
void createOrder_WhenProductAvailable_ShouldSucceed() { }
// 或使用中文命名(中文团队适用)
@Test
void 用户不存在时应该抛出异常() { }
2. 测试结构
使用 Given-When-Then 模式组织测试代码:
@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. 测试隔离
每个测试应该独立运行,不依赖其他测试的状态:
@SpringBootTest
class IsolatedTest {
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
// 重置状态
userRepository.deleteAll();
reset(mockService);
}
@AfterEach
void tearDown() {
// 清理资源
}
}
使用 @DirtiesContext:
当测试修改了 Spring 上下文状态时,使用 @DirtiesContext 确保后续测试使用新的上下文:
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class ContextModifyingTest {
// 每个测试方法后重置 Spring 上下文
// 注意:这会降低测试速度,谨慎使用
}
4. 一个测试一个断言场景
// 不推荐:多个场景混在一起
@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("@");
}
5. 避免测试实现细节
测试应该验证行为,而非实现:
// 不推荐:测试内部实现
@Test
void testInternalListSize() {
userService.addUser(user);
assertThat(userService.getInternalList().size()).isEqualTo(1);
}
// 推荐:测试公开行为
@Test
void shouldReturnCreatedUser() {
userService.create(user);
assertThat(userService.findById(1L)).isPresent();
}
6. 合理使用 Mock
应该 Mock 的场景:
- 外部服务(如支付网关、邮件服务)
- 数据库操作(单元测试时)
- 时间相关的操作
- 昂贵或耗时的操作
不应该 Mock 的场景:
- 值对象(如 User、Order)
- 简单的业务逻辑
- 你不拥有的代码(应该集成测试验证)
// 正确的 Mock 使用
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private PaymentGateway paymentGateway; // Mock 外部服务
@Mock
private EmailService emailService; // Mock 基础设施
@InjectMocks
private OrderService orderService;
@Test
void shouldProcessOrder() {
// 只 Mock 外部依赖,业务逻辑使用真实代码
when(paymentGateway.charge(any())).thenReturn(true);
Order order = orderService.processOrder(orderDto);
assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
}
}
7. 测试异常情况
不要只测试正常流程,还要测试异常情况:
@Test
void shouldThrowExceptionWhenOrderNotFound() {
assertThatThrownBy(() -> orderService.getOrder(999L))
.isInstanceOf(OrderNotFoundException.class)
.hasMessage("订单不存在: 999");
}
@Test
void shouldReturnEmptyWhenUserNotExists() {
Optional<User> user = userService.findById(999L);
assertThat(user).isEmpty();
}
@Test
void shouldFailValidationWhenEmailIsInvalid() {
UserDTO dto = new UserDTO("张三", "invalid-email");
assertThatThrownBy(() -> userService.create(dto))
.isInstanceOf(ValidationException.class);
}
8. 参数化测试
使用参数化测试减少重复代码:
@ParameterizedTest
@CsvSource({
"张三, [email protected], true",
"'', [email protected], false", // 空名字
"张三, invalid-email, false", // 无效邮箱
"张三, '', false" // 空邮箱
})
void shouldValidateUserInput(String name, String email, boolean expectedValid) {
UserDTO dto = new UserDTO(name, email);
boolean isValid = validator.validate(dto);
assertThat(isValid).isEqualTo(expectedValid);
}
@ParameterizedTest
@MethodSource("provideTestUsers")
void shouldCalculateDiscount(User user, BigDecimal expectedDiscount) {
BigDecimal discount = discountService.calculate(user);
assertThat(discount).isEqualByComparingTo(expectedDiscount);
}
static Stream<Arguments> provideTestUsers() {
return Stream.of(
Arguments.of(new User("VIP", 1000), new BigDecimal("0.20")),
Arguments.of(new User("REGULAR", 500), new BigDecimal("0.10")),
Arguments.of(new User("NEW", 100), new BigDecimal("0.05"))
);
}
9. 测试时间相关代码
使用可注入的 Clock 来测试时间相关逻辑:
// 生产代码
@Service
public class SubscriptionService {
private final Clock clock;
public SubscriptionService(Clock clock) {
this.clock = clock;
}
public boolean isExpired(Subscription sub) {
return sub.getEndDate().isBefore(LocalDate.now(clock));
}
}
// 测试代码
@Test
void shouldDetectExpiredSubscription() {
// 使用固定的时间
Clock fixedClock = Clock.fixed(
Instant.parse("2024-01-15T00:00:00Z"),
ZoneId.systemDefault()
);
SubscriptionService service = new SubscriptionService(fixedClock);
Subscription sub = new Subscription(LocalDate.of(2024, 1, 10));
assertThat(service.isExpired(sub)).isTrue();
}
10. 测试异步代码
@Test
void shouldProcessAsync() {
// 使用 Awaitility 等待异步操作完成
await().atMost(5, TimeUnit.SECONDS)
.until(() -> userService.getUserCount() > 0);
assertThat(userService.getUserCount()).isEqualTo(1);
}
// 使用 CompletableFuture 测试
@Test
void shouldReturnCompletableFuture() throws Exception {
CompletableFuture<User> future = userService.findUserAsync(1L);
User user = future.get(3, TimeUnit.SECONDS);
assertThat(user.getName()).isEqualTo("张三");
}
运行测试
Maven 命令
# 运行所有测试
mvn test
# 运行指定测试类
mvn test -Dtest=UserTest
# 运行指定测试方法
mvn test -Dtest=UserTest#shouldCreateUser
# 运行匹配模式的测试类
mvn test -Dtest="*IntegrationTest"
# 跳过测试
mvn package -DskipTests
# 跳过测试编译
mvn package -Dmaven.test.skip=true
# 运行测试并生成报告
mvn test site
Gradle 命令
# 运行所有测试
gradle test
# 运行指定测试类
gradle test --tests UserTest
# 运行指定测试方法
gradle test --tests UserTest.shouldCreateUser
# 持续测试模式
gradle test --continuous
# 跳过测试
gradle build -x test
测试覆盖率
使用 JaCoCo 生成覆盖率报告:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<id>prepare-agent</id>
<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>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
运行后,报告生成在 target/site/jacoco/index.html。
测试工具类
TestEntityManager
TestEntityManager 是 JPA 测试的辅助类,提供了便捷的实体操作方法:
@DataJpaTest
class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindUserByEmail() {
// 持久化并立即刷新
User user = entityManager.persistAndFlush(
new User("张三", "[email protected]")
);
// 持久化但不刷新(需要手动刷新)
entityManager.persist(user);
entityManager.flush();
// 查找实体
User found = entityManager.find(User.class, 1L);
// 刷新实体状态
entityManager.refresh(found);
// 移除实体
entityManager.remove(found);
}
}
TestRestTemplate
TestRestTemplate 是 RestTemplate 的测试友好版本:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserApiTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldGetUser() {
ResponseEntity<User> response = restTemplate.getForEntity(
"/api/users/1", User.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().getName()).isEqualTo("张三");
}
@Test
void shouldPostWithAuthentication() {
// 带认证的请求
TestRestTemplate authRestTemplate = restTemplate
.withBasicAuth("admin", "password");
ResponseEntity<String> response = authRestTemplate.postForEntity(
"/api/admin/users", userDto, String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
@Test
void shouldExchangeWithHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Custom-Header", "value");
HttpEntity<UserDTO> request = new HttpEntity<>(userDto, headers);
ResponseEntity<User> response = restTemplate.exchange(
"/api/users",
HttpMethod.POST,
request,
User.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
}
OutputCaptureExtension
用于捕获控制台输出(如日志):
@ExtendWith(OutputCaptureExtension.class)
class LoggingTest {
@Test
void shouldLogInfo(CapturedOutput output) {
service.doSomething();
assertThat(output).contains("Processing started");
assertThat(output).contains("Processing completed");
}
}
ReflectionTestUtils
用于测试私有字段和方法:
@Test
void shouldSetPrivateField() {
UserService service = new UserService();
// 设置私有字段
ReflectionTestUtils.setField(service, "maxRetryCount", 5);
// 获取私有字段
int count = ReflectionTestUtils.getField(service, "maxRetryCount");
// 调用私有方法
String result = ReflectionTestUtils.invokeMethod(
service, "privateMethod", "arg1", "arg2"
);
}
测试数据准备
使用 @Sql 注解
@SpringBootTest
class UserSqlTest {
@Test
@Sql(scripts = "classpath:test-data.sql")
void shouldLoadTestData() {
// 执行测试,数据已初始化
List<User> users = userRepository.findAll();
assertThat(users).hasSize(3);
}
@Test
@Sql(scripts = "classpath:cleanup.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:test-data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
void shouldRunWithCleanup() {
// 测试前清理 -> 加载数据 -> 测试 -> 测试后清理
}
}
使用测试构建器模式
public class UserTestDataBuilder {
private String name = "默认用户";
private String email = "[email protected]";
private Integer age = 25;
private UserStatus status = UserStatus.ACTIVE;
public static UserTestDataBuilder aUser() {
return new UserTestDataBuilder();
}
public UserTestDataBuilder withName(String name) {
this.name = name;
return this;
}
public UserTestDataBuilder withEmail(String email) {
this.email = email;
return this;
}
public UserTestDataBuilder withAge(Integer age) {
this.age = age;
return this;
}
public User build() {
User user = new User();
user.setName(name);
user.setEmail(email);
user.setAge(age);
user.setStatus(status);
return user;
}
}
// 使用
@Test
void shouldCreateUser() {
User user = UserTestDataBuilder.aUser()
.withName("张三")
.withEmail("[email protected]")
.build();
}
使用 Factory 方法
class TestFixtures {
public static User defaultUser() {
return new User("张三", "[email protected]");
}
public static User userWithName(String name) {
return new User(name, name.toLowerCase() + "@example.com");
}
public static Order pendingOrder() {
Order order = new Order();
order.setStatus(OrderStatus.PENDING);
order.setItems(List.of(new OrderItem("product", 1)));
return order;
}
}
// 使用
@Test
void shouldProcessOrder() {
User user = TestFixtures.defaultUser();
Order order = TestFixtures.pendingOrder();
}
测试常见问题
问题一:Spring 上下文缓存
问题:多个测试使用相同配置时,Spring 会缓存上下文以加速测试。但如果测试修改了上下文(如 Mock Bean),会导致上下文不共享,增加启动时间。
解决方案:
// 不推荐:每个测试类都有不同的 Mock Bean,导致多个上下文
@SpringBootTest
class Test1 {
@MockBean
private ServiceA serviceA;
}
@SpringBootTest
class Test2 {
@MockBean
private ServiceB serviceB; // 不同的 Mock Bean,创建新上下文
}
// 推荐:统一配置 Mock Bean
@SpringBootTest
@MockBean({ServiceA.class, ServiceB.class})
abstract class BaseTest {
// 所有子类共享相同的 Mock 配置
}
class Test1 extends BaseTest { }
class Test2 extends BaseTest { }
问题二:事务不回滚
问题:使用 @Transactional 测试时,某些操作没有被回滚。
原因:
- 使用了
REQUIRES_NEW传播特性 - 测试方法中手动提交了事务
- 使用了异步操作
解决方案:
@SpringBootTest
@Transactional
class TransactionTest {
@Test
void shouldRollback() {
// 测试结束后会自动回滚
userRepository.save(new User("张三"));
// 不要手动提交或使用新事务
}
@Test
@Commit // 明确表示不回滚
void shouldNotRollback() {
// 这个测试会提交事务
}
@Test
@Rollback(false) // 同 @Commit
void shouldCommit() {
}
}
问题三:异步测试超时
问题:异步操作导致测试提前结束,无法验证结果。
解决方案:
// 使用 Awaitility(推荐)
@Test
void shouldProcessAsync() {
service.processAsync();
await().atMost(5, TimeUnit.SECONDS)
.pollInterval(100, TimeUnit.MILLISECONDS)
.until(() -> resultRepository.count() > 0);
}
// 使用 CountDownLatch
@Test
void shouldProcessAsyncWithLatch() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
service.processAsync(() -> latch.countDown());
boolean completed = latch.await(5, TimeUnit.SECONDS);
assertThat(completed).isTrue();
}
问题四:静态方法 Mock
问题:Mockito 不能直接 Mock 静态方法。
解决方案:使用 Mockito Inline(Mockito 3.4+)
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<scope>test</scope>
</dependency>
@Test
void shouldMockStaticMethod() {
try (MockedStatic<UtilityClass> mocked = mockStatic(UtilityClass.class)) {
mocked.when(() -> UtilityClass.staticMethod()).thenReturn("mocked");
String result = service.useStaticMethod();
assertThat(result).isEqualTo("mocked");
}
// try-with-resources 结束后,静态 Mock 自动清除
}
问题五:测试随机失败
可能原因:
- 测试之间存在依赖
- 时间敏感的测试
- 并发问题
- 外部资源不可用
解决方案:
// 隔离测试状态
@BeforeEach
void setUp() {
// 重置所有状态
}
// 固定时间
@Test
void shouldTestWithFixedTime() {
Clock fixedClock = Clock.fixed(Instant.now(), ZoneId.systemDefault());
// 使用 fixedClock
}
// 重试机制(JUnit 5)
@RetryableTest(maxAttempts = 3)
void flakyTest() {
// 最多重试 3 次
}
小结
本章详细介绍了 Spring Boot 的测试体系:
测试框架:
- JUnit 5:现代测试框架,支持参数化测试、嵌套测试
- AssertJ:流式断言库,提供丰富的断言方法
- Mockito:Mock 框架,用于模拟依赖组件
测试类型:
- 单元测试:测试单个类或方法,运行快速
- 切片测试:使用
@WebMvcTest、@DataJpaTest等测试特定层 - 集成测试:使用
@SpringBootTest测试组件集成
Spring Boot 3.4 新特性:
- MockMvcTester:基于 AssertJ 的 MockMvc 替代方案
- RestTestClient:现代化的 HTTP 客户端测试工具
- 改进的测试支持和 Mock Bean 支持
最佳实践:
- 使用 Given-When-Then 结构组织测试
- 一个测试专注一个断言场景
- 合理使用 Mock,避免过度 Mock
- 测试行为而非实现细节
练习
- 为一个 Service 类编写完整的单元测试,使用 Mockito 模拟依赖
- 使用 MockMvc 测试 Controller 的 CRUD 操作
- 使用 MockMvcTester(Spring Boot 3.4+)重写 Controller 测试
- 使用 Testcontainers 进行数据库集成测试
- 编写参数化测试,使用
@CsvSource和@MethodSource - 配置 JaCoCo 并生成覆盖率报告
- 为异步方法编写测试