前言
单元测试是保证代码质量的重要手段。Spring Boot 集成了 JUnit 5 和 Mockito,提供了强大的测试支持。本文将介绍 Spring Boot 单元测试的完整方案。
快速开始
1. 添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
2. 基础测试
@SpringBootTest
class DemoApplicationTests {
@Test
void contextLoads() {
// 验证应用上下文可以加载
}
}
3. JUnit 5 基础
@DisplayName("JUnit 5 基础测试")
class JUnit5Tests {
@Test
@DisplayName("测试加法")
void testAdd() {
assertEquals(4, 2 + 2);
}
@Test
@DisplayName("测试集合")
void testCollection() {
List<String> list = List.of("a", "b", "c");
assertTrue(list.contains("a"));
assertThat(list).hasSize(3);
}
@Test
@DisplayName("测试异常")
void testException() {
assertThrows(IllegalArgumentException.class, () -> {
throw new IllegalArgumentException("error");
});
}
@Test
@DisplayName("测试超时")
void testTimeout() {
assertTimeout(Duration.ofMillis(100), () -> {
Thread.sleep(50);
});
}
}
Mockito mocking
1. 创建 Mock 对象
@ExtendWith(MockitoExtension.class)
class MockitoTests {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void testMock() {
// 定义 Mock 行为
when(userRepository.findById(1L))
.thenReturn(Optional.of(new User(1L, "test")));
// 执行测试
User user = userService.findById(1L);
// 验证结果
assertNotNull(user);
assertEquals("test", user.getUsername());
// 验证调用
verify(userRepository).findById(1L);
}
}
2. 常用 Mock 方法
@ExtendWith(MockitoExtension.class)
class MockMethodsTests {
@Mock
private OrderService orderService;
@Test
void testWhen() {
// 定义返回值
when(orderService.findById(1L))
.thenReturn(new Order(1L, "ORDER001"));
when(orderService.findByStatus(OrderStatus.PAID))
.thenReturn(List.of());
}
@Test
void testThenThrow() {
// 定义异常
when(orderService.findById(999L))
.thenThrow(new NotFoundException("订单不存在"));
assertThrows(NotFoundException.class, () -> {
orderService.findById(999L);
});
}
@Test
void testThenAnswer() {
// 自定义返回值
when(orderService.findById(anyLong()))
.thenAnswer(invocation -> {
Long id = invocation.getArgument(0);
return new Order(id, "ORDER" + id);
});
Order order = orderService.findById(100L);
assertEquals("ORDER100", order.getOrderNo());
}
@Test
void testDoNothing() {
// Void 方法 Mock
doNothing().when(orderService).delete(1L);
orderService.delete(1L);
verify(orderService).delete(1L);
}
@Test
void testArgumentMatchers() {
// 参数匹配器
when(orderService.findByUserId(anyLong()))
.thenReturn(List.of());
when(orderService.findByUserId(eq(1L)))
.thenReturn(List.of(new Order(1L, "ORDER001")));
when(orderService.findByUserId(gt(100L)))
.thenReturn(List.of());
}
}
3. Spy 部分 Mock
@Test
void testSpy() {
List<String> list = new ArrayList<>();
list.add("a");
// Spy 保留真实对象行为
List<String> spyList = spy(list);
doNothing().when(spyList).clear();
spyList.add("b");
spyList.clear(); // Mock
assertEquals(1, spyList.size()); // 真实行为
}
测试切片
1. @WebMvcTest Controller 测试
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void testGetUser() throws Exception {
// 准备数据
UserDTO user = new UserDTO(1L, "test", "test@example.com");
when(userService.findById(1L)).thenReturn(user);
// 执行请求
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.id").value(1))
.andExpect(jsonPath("$.data.username").value("test"));
// 验证调用
verify(userService).findById(1L);
}
@Test
void testCreateUser() throws Exception {
UserDTO user = new UserDTO(1L, "test", "test@example.com");
when(userService.create(any())).thenReturn(user);
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"test\",\"email\":\"test@example.com\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(1));
}
}
2. @DataJpaTest Repository 测试
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void testSave() {
User user = new User();
user.setUsername("test");
user.setEmail("test@example.com");
User saved = userRepository.save(user);
assertNotNull(saved.getId());
assertEquals("test", saved.getUsername());
}
@Test
void testFindById() {
User user = new User();
user.setUsername("test");
entityManager.persistAndFlush(user);
Optional<User> found = userRepository.findById(user.getId());
assertTrue(found.isPresent());
assertEquals("test", found.get().getUsername());
}
@Test
void testFindByUsername() {
User user = new User();
user.setUsername("test");
entityManager.persistAndFlush(user);
Optional<User> found = userRepository.findByUsername("test");
assertTrue(found.isPresent());
}
}
3. @ServiceTest Service 测试
@SpringBootTest
class UserServiceTest {
@Autowired
private UserService userService;
@MockBean
private UserRepository userRepository;
@Test
void testCreateUser() {
User user = new User();
user.setUsername("test");
when(userRepository.save(any())).thenReturn(user);
UserDTO result = userService.create(new UserCreateDTO("test", "test@example.com"));
assertNotNull(result);
verify(userRepository).save(any());
}
}
4. @RestClientTest 客户端测试
@RestClientTest(ExternalService.class)
class ExternalServiceTest {
@Autowired
private ExternalService externalService;
@Autowired
private MockRestServiceServer mockServer;
@Test
void testGetUserInfo() {
mockServer.expect(requestTo("http://api.example.com/users/1"))
.andRespond(withSuccess("{\"id\":1,\"name\":\"test\"}",
MediaType.APPLICATION_JSON));
UserInfo userInfo = externalService.getUserInfo(1L);
assertEquals(1L, userInfo.getId());
assertEquals("test", userInfo.getName());
}
}
参数化测试
1. @ValueSource
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void testWithValueSource(int number) {
assertTrue(number > 0);
}
@ParameterizedTest
@ValueSource(strings = {"a", "b", "c"})
void testWithStringSource(String value) {
assertNotNull(value);
}
2. @CsvSource
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"10, 20, 30",
"100, 200, 300"
})
void testWithCsvSource(int a, int b, int expected) {
assertEquals(expected, a + b);
}
@ParameterizedTest
@CsvSource({
"test, true",
"", false",
"null, false"
})
void testNotBlank(String input, boolean expected) {
assertEquals(expected, StringUtils.isNotBlank(input));
}
3. @MethodSource
@ParameterizedTest
@MethodSource("provideUsers")
void testWithMethodSource(User user) {
assertNotNull(user);
assertNotNull(user.getUsername());
}
static Stream<Arguments> provideUsers() {
return Stream.of(
Arguments.of(new User(1L, "user1")),
Arguments.of(new User(2L, "user2")),
Arguments.of(new User(3L, "user3"))
);
}
4. @EnumSource
@ParameterizedTest
@EnumSource(OrderStatus.class)
void testWithEnumSource(OrderStatus status) {
assertNotNull(status);
}
@ParameterizedTest
@EnumSource(value = OrderStatus.class,
names = {"PAID", "SHIPPED"})
void testWithEnumSourceFilter(OrderStatus status) {
assertTrue(status == OrderStatus.PAID || status == OrderStatus.SHIPPED);
}
最佳实践
1. 测试命名规范
@Nested
@DisplayName("用户服务测试")
class UserServiceTest {
@Nested
@DisplayName("创建用户")
class CreateUser {
@Test
@DisplayName("成功创建用户")
void should_create_user_success() {
// ...
}
@Test
@DisplayName("用户名不能为空")
void should_fail_when_username_is_empty() {
// ...
}
@Test
@DisplayName("邮箱格式不正确")
void should_fail_when_email_is_invalid() {
// ...
}
}
@Nested
@DisplayName("删除用户")
class DeleteUser {
@Test
@DisplayName("成功删除用户")
void should_delete_user_success() {
// ...
}
}
}
2. AAA 模式
@Test
void testAAA() {
// Arrange - 准备
User user = new User(1L, "test");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
// Act - 执行
UserDTO result = userService.findById(1L);
// Assert - 断言
assertNotNull(result);
assertEquals("test", result.getUsername());
verify(userRepository).findById(1L);
}
3. 测试覆盖率
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</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>
# 生成覆盖率报告
mvn clean test
# 查看报告
# target/site/jacoco/index.html
4. 测试隔离
@TestInstance(TestInstance.Lifecycle.PER_METHOD)
class IsolatedTests {
@BeforeEach
void setUp() {
// 每个测试方法前执行
}
@AfterEach
void tearDown() {
// 每个测试方法后执行
}
}
5. 断言库
@Test
void testAssertJ() {
User user = new User(1L, "test");
// 流畅断言
assertThat(user)
.isNotNull()
.extracting("id", "username")
.containsExactly(1L, "test");
// 集合断言
List<User> users = List.of(user);
assertThat(users)
.hasSize(1)
.extracting("username")
.contains("test");
// 异常断言
assertThatThrownBy(() -> {
throw new IllegalArgumentException("error");
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("error");
}
总结
单元测试要点:
- ✅ JUnit 5 - 注解、断言、参数化
- ✅ Mockito - Mock、Spy、验证
- ✅ 测试切片 - @WebMvcTest、@DataJpaTest
- ✅ 参数化测试 - @ValueSource、@CsvSource
- ✅ 最佳实践 - AAA 模式、覆盖率、断言库
单元测试是代码质量的第一道防线。