springboot

单元测试与集成测试

By AI-Writer 10 min read

单元测试与集成测试

测试是软件质量的基石。Spring Boot 提供了一套完整的测试支持,从轻量级的单元测试到全量集成测试都有对应的方案。本文涵盖 Spring Boot Test 的核心工具和最佳实践。

测试依赖

spring-boot-starter-test 已包含所有常用测试库:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<!-- 包含:JUnit 5、Mockito、AssertJ、Spring Test -->

Spring Boot 3.x 使用 JUnit 5(Jupiter)作为默认测试框架。

单元测试:Service 层

使用 @MockBean 模拟依赖

java
@ExtendWith(SpringExtension.class)  // JUnit 5 + Spring Test 集成
@SpringBootTest
class OrderServiceTest {

    @MockBean
    private OrderRepository orderRepository;

    @MockBean
    private PaymentGateway paymentGateway;

    @MockBean
    private NotificationService notificationService;

    @InjectMocks
    private OrderService orderService;

    @Test
    void placeOrder_should_save_and_notify() {
        // Given:设置 mock 行为
        Order order = new Order(1L, 100.0);
        when(orderRepository.save(any(Order.class))).thenReturn(order);
        when(paymentGateway.charge(any())).thenReturn(PaymentResult.SUCCESS);

        // When:执行测试方法
        OrderResult result = orderService.placeOrder(order);

        // Then:验证结果和交互
        assertThat(result.isSuccess()).isTrue();
        assertThat(result.getOrderId()).isEqualTo(1L);

        // 验证方法调用次数
        verify(orderRepository, times(1)).save(any(Order.class));
        verify(paymentGateway, times(1)).charge(any());
        verify(notificationService, never()).sendFailureNotice(any());  // 失败通知不应发送
    }

    @Test
    void placeOrder_should_fail_when_payment_declined() {
        // Given
        Order order = new Order(1L, 100.0);
        when(paymentGateway.charge(any())).thenReturn(PaymentResult.DECLINED);

        // When
        assertThatThrownBy(() -> orderService.placeOrder(order))
                .isInstanceOf(PaymentDeclinedException.class)
                .hasMessageContaining("Payment was declined");

        // 验证订单未保存
        verify(orderRepository, never()).save(any());
    }
}

切片测试:@WebMvcTest

@WebMvcTest 只启动 Web 层(Spring MVC),不启动完整容器,速度快:

java
@WebMvcTest(UserController.class)  // 只加载 UserController 及其相关组件
@Import(TestSecurityConfig.class)  // 如需自定义安全配置
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private UserService userService;

    @Test
    @WithMockUser(roles = "USER")  // 模拟已认证用户
    void getUser_should_return_200() throws Exception {
        User user = new User(1L, "alice", "alice@example.com");
        when(userService.findById(1L)).thenReturn(user);

        mockMvc.perform(get("/api/users/1")
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.username").value("alice"))
                .andExpect(jsonPath("$.email").value("alice@example.com"));
    }

    @Test
    @WithMockUser(roles = "USER")
    void createUser_should_return_400_when_invalid() throws Exception {
        CreateUserRequest invalid = new CreateUserRequest("", "not-an-email");

        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(invalid)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value(400));
    }

    @Test
    void getUser_should_return_401_when_unauthenticated() throws Exception {
        mockMvc.perform(get("/api/users/1"))
                .andExpect(status().isUnauthorized());
    }
}

AssertJ 断言链

AssertJ 的链式断言比 JUnit 原生断言更具可读性:

java
// AssertJ 断言示例
assertThat(user)
    .isNotNull()
    .extracting(User::getUsername, User::getEmail)
    .containsExactly("alice", "alice@example.com");

assertThatThrownBy(() -> userService.create(null))
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessageContaining("user")
    .hasMessageContaining("not be null")
    .hasFieldOrPropertyWithValue("errorCode", "INVALID_USER");

assertThat(list)
    .hasSize(3)
    .filteredOn(u -> u.getAge() > 18)
    .hasSize(2)
    .extracting(User::getUsername)
    .containsExactlyInAnyOrder("alice", "bob");

集成测试:@SpringBootTest

需要启动完整 Spring 容器的测试:

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc  // 提供 MockMvc Bean
class ArticleIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ArticleRepository articleRepository;

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @BeforeEach
    void setUp() {
        articleRepository.deleteAll();  // 每个测试前清理数据
    }

    @Test
    void full_workflow_should_work() throws Exception {
        // 1. 创建文章
        String createJson = """
            {
                "title": "Spring Boot Testing",
                "content": "Testing is important.",
                "authorId": 1
            }
            """;

        ResultActions createResult = mockMvc.perform(post("/api/articles")
                .contentType(MediaType.APPLICATION_JSON)
                .content(createJson));

        createResult.andExpect(status().isCreated())
                    .andExpect(jsonPath("$.id").exists());

        Long articleId = UUID.fromString(
            jsonPath("$.id").from(createResult.andReturn().getResponse().getContentAsString())
        );

        // 2. 查询文章
        mockMvc.perform(get("/api/articles/{id}", articleId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title").value("Spring Boot Testing"));

        // 3. 删除文章
        mockMvc.perform(delete("/api/articles/{id}", articleId))
                .andExpect(status().isNoContent());

        // 4. 确认已删除
        mockMvc.perform(get("/api/articles/{id}", articleId))
                .andExpect(status().isNotFound());
    }
}

测试数据库配置

H2 内存数据库(开发/测试)

yaml
# src/test/resources/application.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create-drop  # 测试时自动创建和删除表
    show-sql: true
    properties:
      hibernate:
        format_sql: true
  h2:
    console:
      enabled: true  # 浏览器访问 http://localhost:8080/h2-console
xml
<!-- test scope -->
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

Testcontainers(真实数据库)

Testcontainers 使用 Docker 容器运行真实数据库(MySQL、PostgreSQL 等),测试更接近生产环境:

java
@SpringBootTest
@Testcontainers  // 自动管理容器生命周期
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
        registry.add("spring.datasource.driver-class-name", mysql::getDriverClassName);
    }

    @Autowired
    private UserRepository userRepository;

    @Test
    void save_and_find() {
        User user = new User("alice", "alice@example.com");
        userRepository.save(user);

        Optional<User> found = userRepository.findByEmail("alice@example.com");
        assertThat(found).isPresent()
                         .hasValueSatisfying(u ->
                             assertThat(u.getUsername()).isEqualTo("alice")
                         );
    }
}

参数化测试

JUnit 5 的参数化测试减少重复代码:

java
@ParameterizedTest
@CsvSource({
    "alice@example.com, true",
    "bob, false",
    "carol@test.org, true",
    "not-an-email, false"
})
@DisplayName("邮箱格式校验")
void email_validation(String email, boolean expected) {
    assertThat(isValidEmail(email)).isEqualTo(expected);
}

@ParameterizedTest
@EnumSource(mode = EnumSource.Mode.EXCLUDED, names = {"UNKNOWN"})
@DisplayName("UserStatus 枚举值转换")
void user_status_serialization(UserStatus status) {
    assertThat(status).isNotEqualTo(UserStatus.UNKNOWN);
}

@DataJpaTest 切片

只启动 JPA 相关组件,自动配置内存数据库和 Hibernate:

java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {

    @Autowired
    private TestEntityManager entityManager;

    @Autowired
    private UserRepository userRepository;

    @Test
    void findByEmail_should_find_existing_user() {
        entityManager.persist(new User("alice", "alice@example.com"));
        entityManager.flush();

        Optional<User> user = userRepository.findByEmail("alice@example.com");

        assertThat(user).isPresent()
                        .hasValueSatisfying(u ->
                            assertThat(u.getUsername()).isEqualTo("alice")
                        );
    }
}

测试配置类

java
// src/test/java/.../TestSecurityConfig.java
@TestConfiguration
public class TestSecurityConfig {

    @Bean
    public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
        return http.build();
    }
}

测试执行

bash
# 运行所有测试
./mvnw test

# 运行单个测试类
./mvnw test -Dtest=UserControllerTest

# 运行包含特定名称的测试
./mvnw test -Dtest="*ControllerTest,*ServiceTest"

# 生成测试覆盖率报告
./mvnw test jacoco:report

小结

  • @MockBean 在 Spring 容器中注入 Mockito Mock 对象,适合 Service 层测试
  • @WebMvcTest 切片测试只启动 MVC 层,速度快,适合 Controller 单元测试
  • @SpringBootTest 启动完整容器,适合集成测试和端到端场景
  • H2 内存数据库适合快速测试,Testcontainers 提供真实数据库环境
  • AssertJ 链式断言语法清晰、可读性强,是 Spring Boot Test 的默认断言库
  • JUnit 5 参数化测试(@ParameterizedTest)显著减少重复的测试用例代码
#springboot #testing #junit #mockito #spring-boot-test

评论

A

Written by

AI-Writer

Related Articles

springboot
#2

Spring Boot 自动配置原理

深入理解 @SpringBootApplication 注解组合、自动配置生效机制(spring.factories 与 AutoConfiguration.imports)、自定义 Starter 的编写方法

Read More
springboot
#7

Spring Security 安全认证

Spring Security 6.x 核心架构、认证与授权概念、基于 JWT 的无状态登录实现、OAuth 2.0 资源服务器入门、@PreAuthorize 方法级安全注解

Read More
springboot
#3

依赖注入与 Bean 管理

深入理解 Spring IoC 容器核心概念、@Bean/@Component/@Configuration 注解使用、构造器注入与 Setter 注入、@Autowired 与 @Qualifier 精确注入、Bean 作用域与优先级控制

Read More