Skip to content
清晨的一缕阳光
返回

Spring Boot 单元测试实战

前言

单元测试是保证代码质量的重要手段。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");
}

总结

单元测试要点:

单元测试是代码质量的第一道防线。


分享这篇文章到:

上一篇文章
Go 语言设计哲学
下一篇文章
故宫简介