跳到主要内容

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测试框架
MockitoMock 框架
AssertJ断言库
Hamcrest匹配器库
JSONassertJSON 断言
JsonPathJSON 路径查询

单元测试

基本单元测试

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 客户端测试工具。它可以用于两种场景:

  1. 模拟环境:配合 @AutoConfigureMockMvc 使用,不需要启动真实服务器
  2. 真实服务器:配合 @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

特性MockMvcRestTestClientWebTestClient
适用场景Spring MVCSpring MVCSpring 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

TestRestTemplateRestTemplate 的测试友好版本:

@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
  • 测试行为而非实现细节

练习

  1. 为一个 Service 类编写完整的单元测试,使用 Mockito 模拟依赖
  2. 使用 MockMvc 测试 Controller 的 CRUD 操作
  3. 使用 MockMvcTester(Spring Boot 3.4+)重写 Controller 测试
  4. 使用 Testcontainers 进行数据库集成测试
  5. 编写参数化测试,使用 @CsvSource@MethodSource
  6. 配置 JaCoCo 并生成覆盖率报告
  7. 为异步方法编写测试

参考资源

官方文档

第三方文档

教程和文章