跳到主要内容

测试

测试是保证代码质量的重要手段。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测试框架
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("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>

小结

本章我们学习了:

  1. 测试概述:测试分层和依赖
  2. 单元测试:JUnit 5、AssertJ 断言
  3. Mock 测试:Mockito 使用
  4. Controller 测试:MockMvc 使用
  5. 集成测试:@SpringBootTest、Testcontainers
  6. 数据层测试:@DataJpaTest
  7. 最佳实践:命名、结构、隔离

练习

  1. 为一个 Service 类编写完整的单元测试
  2. 使用 MockMvc 测试 Controller 的 CRUD 操作
  3. 使用 Testcontainers 进行数据库集成测试
  4. 编写参数化测试
  5. 配置 JaCoCo 并生成覆盖率报告

参考资源