1. 项目概述:为什么我们需要“最佳实践”?

在SpringBoot项目里写单元测试和集成测试,这事儿听起来简单,但真动起手来,十个开发者里得有八个踩过坑。我见过太多项目,测试代码写得比业务代码还难懂,或者干脆就是一堆“伪测试”——跑是能跑通,但业务逻辑一改,测试要么全挂,要么毫无感知。问题出在哪?往往不是JUnit或者Mockito这些工具本身,而是没有一套清晰、可落地、能贯穿项目生命周期的“最佳实践”。

这个标题里的关键词很明确: SpringBoot 单元测试 Mockito JUnit5 集成测试 。把它们串起来,核心要解决的就是一个现代Java后端项目中的测试体系问题。单元测试负责验证单个“零件”(如一个Service方法)的逻辑正确性,要求快速、隔离;集成测试则关注“零件”组装后的协作,比如Controller调用Service、Service访问数据库这一整条链路是否通畅。Mockito在这里扮演“替身演员”的角色,把那些复杂的、慢速的依赖(如数据库、第三方API)模拟出来,让我们能专注于测试目标代码本身。而JUnit5是这一切的舞台和规则制定者。

所以,这篇实战总结,就是想把我这些年趟过的坑、总结出来的套路,系统地梳理一遍。目标不是教你某个注解怎么用(那是官方文档的事),而是告诉你,在一个真实的、持续迭代的SpringBoot项目里,如何搭建一个 健壮、可维护、高效 的测试套件。你会看到如何选择测试策略、如何优雅地使用Mockito、如何利用JUnit5的新特性,以及如何让集成测试既可靠又不至于慢得让人无法忍受。

2. 测试策略与框架选型:不只是版本号那么简单

在动手写第一行测试代码之前,先想清楚测试策略,这能省下后期大量的重构时间。很多人一上来就 @SpringBootTest 拉满整个应用上下文,然后抱怨测试跑得太慢。这其实是策略上的失误。

2.1 单元测试 vs. 集成测试:明确边界

首先必须厘清概念,因为两者的编写方式和目标截然不同。

  • 单元测试 :测试对象是 单个类 (通常是Service、Util或Mapper)中的 单个方法 。核心原则是 隔离 。所有外部依赖(如其他的Service、Repository、第三方客户端)都必须被Mock或Stub。它的执行速度应该极快(毫秒级),并且不依赖Spring容器、数据库、网络等外部环境。在SpringBoot项目中,这意味着你通常 不应该 使用 @SpringBootTest 来写单元测试,而是用 @ExtendWith(MockitoExtension.class) 这类轻量级扩展。
  • 集成测试 :测试对象是 多个组件 之间的交互。例如,测试一个REST API从Controller到Service再到Repository的完整调用链路,或者测试某个配置类是否正确生效。它需要启动Spring容器,可能会连接真实的数据库或内存数据库(如H2)。它的执行速度较慢,但能发现组件集成时才会出现的问题。

一个常见的误区是把所有测试都写成集成测试。正确的做法是建立 测试金字塔 :底层是大量快速的单元测试,中间是适量的集成测试,顶层是极少量的端到端(E2E)测试。我们的精力应该主要投入到单元测试上。

2.2 JUnit5:为什么是它,而不再是JUnit4?

JUnit5不仅仅是一个版本升级,它是一次架构重构。核心优势在于其模块化(JUnit Platform + Jupiter + Vintage)和强大的扩展模型。

  • 更灵活的生命周期注解 @BeforeEach @AfterEach 替代了 @Before @After ,语义更清晰。 @BeforeAll @AfterAll 必须是静态方法的规定,也提醒我们这些方法用于昂贵的初始化。
  • 强大的断言库 AssertJ 或 JUnit5自带的 Assertions 提供了流式API,断言失败时的信息更友好。例如, assertThat(actualList).containsExactlyInAnyOrder("a", "b", "c")
  • 动态测试与参数化测试 @TestFactory 允许运行时生成测试用例, @ParameterizedTest 配合 @ValueSource @CsvSource 等,让数据驱动测试变得异常简单,极大地减少了重复代码。
  • 标签与条件执行 @Tag 可以对测试分类(如”slow”, “integration”),方便用Maven/Gradle命令选择性执行。 @EnabledOnOs @DisabledIf 等条件注解,让测试能智能地适应不同环境。

选型结论 :对于任何新启动的SpringBoot项目(通常使用Spring Boot 2.2+),应毫不犹豫地选择JUnit5作为测试框架的基石。Spring Boot Starter Test默认就包含了JUnit5。

2.3 Mockito:模拟的艺术与陷阱

Mockito是Java领域事实上的Mock框架标准。它的核心是创建“模拟对象”来替代真实依赖。但“会用”和“用好”之间有巨大差距。

  • @Mock vs @Spy :这是最容易混淆的点。
    • @Mock 创建的是一个“完全虚假”的对象。所有方法默认返回空值(0, false, null, 空集合)。除非你用 when(...).thenReturn(...) 明确指定行为,否则调用它的任何方法都不会执行真实逻辑。
    • @Spy 创建的是一个“部分真实”的对象,它包装了一个真实实例。除非你明确地Stub了某个方法,否则调用它会执行真实对象的逻辑。 慎用Spy ,因为它让你的测试与实现细节耦合更紧,通常意味着你的类可能职责过重,需要拆分。
  • @InjectMocks :这个注解会自动将当前测试类中由 @Mock (或 @Spy )创建的模拟对象,注入到被测试类的实例中。它是快速完成依赖注入的利器。但注意,它用的是 反射 ,对于复杂的继承链或多个构造器,可能会不如显式调用构造器清晰。
  • 行为验证(Verify) :Mockito允许你验证模拟对象是否以预期的参数被调用了预期的次数。例如, verify(userRepository, times(1)).findById(userId) 。这是单元测试中验证“协作”行为的关键。但要避免过度验证(verifying every single call),这会让测试变得脆弱。

实操心得 :我个人的原则是, 优先使用 @Mock 。只有当你想验证某个对象的 部分 方法,而其他方法仍需真实逻辑时(这种情况很少),才考虑 @Spy 。对于 @InjectMocks ,在简单的Service测试中很好用,但如果依赖较多或构造复杂,我更喜欢在 @BeforeEach 方法里用构造器手动创建被测试对象,这样依赖关系一目了然。

3. 单元测试实战:构建坚固的“零件”质检线

单元测试是我们的主战场。目标是写出 专注、快速、稳定 的测试。

3.1 测试环境搭建:轻装上阵

对于纯单元测试,我们不需要启动整个Spring Boot应用。在Maven项目中,确保 pom.xml 引入了正确的依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <!-- Spring Boot 2.7+ 默认排除了JUnit4,使用JUnit5 -->
    <exclusions>
        <exclusion>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
        </exclusion>
    </exclusions>
</dependency>

spring-boot-starter-test 已经包含了JUnit5, Mockito, AssertJ, Hamcrest等我们所需的一切。

创建一个Service的单元测试类:

import org.junit.jupiter.api.Test; // JUnit5
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;

@ExtendWith(MockitoExtension.class) // 关键!使用Mockito扩展,替代@RunWith
class UserServiceUnitTest {

    @Mock
    private UserRepository userRepository; // 模拟依赖

    @InjectMocks
    private UserService userService; // 被测试类,自动注入@Mock对象

    @Test
    void getUserById_shouldReturnUser_whenUserExists() {
        // 1. 准备数据 (Arrange)
        Long userId = 1L;
        User expectedUser = new User(userId, "Alice");
        when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));

        // 2. 执行操作 (Act)
        User actualUser = userService.getUserById(userId);

        // 3. 验证结果 (Assert)
        assertThat(actualUser).isNotNull();
        assertThat(actualUser.getId()).isEqualTo(userId);
        assertThat(actualUser.getName()).isEqualTo("Alice");
        // 验证交互行为
        verify(userRepository, times(1)).findById(userId);
        verify(userRepository, never()).deleteById(any()); // 确保未调用其他方法
    }

    @Test
    void getUserById_shouldThrowException_whenUserNotFound() {
        // Arrange
        Long userId = 999L;
        when(userRepository.findById(userId)).thenReturn(Optional.empty());

        // Act & Assert
        assertThatThrownBy(() -> userService.getUserById(userId))
                .isInstanceOf(UserNotFoundException.class)
                .hasMessageContaining("User not found");
        verify(userRepository).findById(userId);
    }
}

代码解读

  1. @ExtendWith(MockitoExtension.class) :这是JUnit5的方式,用于启用Mockito支持。它替代了JUnit4的 @RunWith(MockitoJUnitRunner.class)
  2. Given-When-Then模式 :这是组织测试代码的经典模式(对应注释中的Arrange-Act-Assert),能让测试逻辑非常清晰。
  3. when(...).thenReturn(...) :这是Stubbing(桩定),定义当模拟方法被调用时返回什么。
  4. assertThat(...) :来自AssertJ,流式断言,可读性极强。
  5. verify(...) :行为验证,确保依赖被以正确的方式调用。

3.2 处理复杂依赖与异常流

现实中的业务逻辑不会总是 findById 这么简单。比如一个订单服务,创建订单时需要检查库存、计算价格、保存订单、发送消息。

@Test
void createOrder_shouldSuccess_whenInventorySufficient() {
    // Arrange
    OrderRequest request = new OrderRequest(/* ... */);
    Product mockProduct = new Product(/* ... */);
    when(inventoryService.checkStock(any())).thenReturn(true);
    when(priceCalculator.calculate(any())).thenReturn(new BigDecimal("100.00"));
    when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
        Order orderToSave = invocation.getArgument(0);
        orderToSave.setId(1000L); // 模拟保存后生成ID
        return orderToSave;
    });
    doNothing().when(messageQueueService).sendOrderCreatedEvent(anyLong());

    // Act
    Order result = orderService.createOrder(request);

    // Assert
    assertThat(result.getId()).isEqualTo(1000L);
    assertThat(result.getStatus()).isEqualTo(OrderStatus.CREATED);
    verify(inventoryService).checkStock(any());
    verify(orderRepository).save(any(Order.class));
    verify(messageQueueService).sendOrderCreatedEvent(1000L);
}

@Test
void createOrder_shouldFail_whenInventoryInsufficient() {
    // Arrange
    when(inventoryService.checkStock(any())).thenReturn(false);

    // Act & Assert
    assertThatThrownBy(() -> orderService.createOrder(new OrderRequest()))
            .isInstanceOf(InsufficientInventoryException.class);
    // 验证库存检查后,后续的保存和发送消息操作一定不能发生
    verify(orderRepository, never()).save(any());
    verify(messageQueueService, never()).sendOrderCreatedEvent(anyLong());
}

关键技巧

  • thenAnswer :当需要根据输入参数动态决定返回值或执行一些操作时使用,比 thenReturn 更灵活。
  • doNothing().when(...) :用于Stub返回类型为 void 的方法。
  • 验证调用顺序 :在复杂流程中,有时需要验证方法调用顺序。可以使用 InOrder InOrder inOrder = inOrder(repo1, repo2); inOrder.verify(repo1).callFirst(); inOrder.verify(repo2).callSecond();
  • never() 验证 :在异常流测试中,确保某些方法一定没有被调用,这和确保某些方法被调用同样重要。

3.3 参数化测试:用数据驱动覆盖边界

JUnit5的参数化测试能大幅减少重复的测试方法。

@ParameterizedTest
@CsvSource({
        "0, 0, 0",
        "1, 2, 3",
        "-1, 1, 0",
        "100, 200, 300"
})
@DisplayName("加法运算测试: {0} + {1} = {2}")
void testAdd(int a, int b, int expectedSum) {
    Calculator calculator = new Calculator();
    int result = calculator.add(a, b);
    assertThat(result).isEqualTo(expectedSum);
}

// 测试边界情况:用户状态转换
@ParameterizedTest
@EnumSource(value = UserStatus.class, names = {"ACTIVE", "SUSPENDED"})
void shouldAllowLogin_whenStatusIsNotDisabled(UserStatus status) {
    User user = new User();
    user.setStatus(status);
    assertThat(loginService.canLogin(user)).isTrue();
}

@ParameterizedTest
@ValueSource(strings = {"", "  ", "\t", "\n"})
void shouldThrowException_whenUsernameIsBlank(String blankUsername) {
    assertThatThrownBy(() -> userService.createUser(blankUsername, "password"))
            .isInstanceOf(InvalidArgumentException.class);
}

注意事项

  • @DisplayName 可以生成更易读的测试报告。
  • @EnumSource 用于遍历枚举。
  • @ValueSource 用于提供一组基本类型的值。
  • @CsvSource 用于提供多参数组合,非常适合测试多种输入输出组合的场景。

4. 集成测试实战:验证“组装”后的运行状态

集成测试需要启动Spring容器,因此我们会用到 @SpringBootTest 。关键是如何让它既有效又不笨重。

4.1 @SpringBootTest 的精准控制

默认情况下, @SpringBootTest 会加载完整的应用上下文,这很慢。我们可以通过注解参数进行优化。

// 示例1:测试Web层(Controller)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // 启动真实Tomcat在随机端口
@AutoConfigureMockMvc // 即使使用RANDOM_PORT,也可以注入MockMvc进行切片测试(更轻量),但这里我们用TestRestTemplate
class UserControllerIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate; // 用于发起HTTP请求

    @Test
    void getUserApi_shouldReturnUser() {
        ResponseEntity<User> response = restTemplate.getForEntity("/api/users/1", User.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getBody().getName()).isEqualTo("Test User");
    }
}

// 示例2:测试数据层(Repository)与数据库交互
@DataJpaTest // 关键!只加载JPA相关的配置,使用嵌入式数据库(如H2)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) // 使用内存数据库替代
class UserRepositoryIntegrationTest {

    @Autowired
    private TestEntityManager entityManager; // 用于持久化测试数据

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldFindUserByName() {
        // 先准备数据到“测试数据库”
        User user = new User("John Doe");
        entityManager.persist(user);
        entityManager.flush();

        // 执行查询
        Optional<User> found = userRepository.findByName("John Doe");

        // 验证
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("John Doe");
    }
}

核心优化点

  • @DataJpaTest :只初始化数据层相关的Bean(Repository, EntityManager, DataSource),速度极快。它默认会回滚事务,保证测试隔离。
  • @WebMvcTest :只初始化Web层相关的Bean(Controller, ControllerAdvice, Filter),不加载Service和Repository。适合单独测试Controller逻辑,需要Mock Service层。
  • @JsonTest :只初始化JSON序列化相关的组件,用于测试Jackson的序列化/反序列化。
  • @AutoConfigureTestDatabase :强制使用内存数据库,避免污染和依赖外部真实数据库。
  • webEnvironment
    • WebEnvironment.MOCK :默认值,不启动真实Servlet容器,使用Mock环境。
    • WebEnvironment.RANDOM_PORT :启动真实容器在随机端口,适合需要测试完整HTTP栈的集成测试。
    • WebEnvironment.NONE :不提供任何Web环境,用于非Web测试。

4.2 测试切片(Test Slices)与MockBean

Spring Boot的“测试切片”思想是集成测试高效的关键。它允许你只加载你关心的那部分应用上下文。

// 使用 @WebMvcTest 切片测试Controller,并Mock Service层
@WebMvcTest(UserController.class) // 只加载UserController及其依赖的Web组件
class UserControllerSliceTest {

    @Autowired
    private MockMvc mockMvc; // 模拟HTTP请求的利器

    @MockBean // 关键!在ApplicationContext中注入一个Mockito Mock,替代真实的Bean
    private UserService userService;

    @Test
    void getUser_shouldReturnOk() throws Exception {
        User mockUser = new User(1L, "Mocked Alice");
        when(userService.getUserById(1L)).thenReturn(mockUser);

        mockMvc.perform(get("/api/users/1")
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name").value("Mocked Alice"));

        verify(userService).getUserById(1L);
    }
}

@MockBean vs @Mock

  • @Mock 是Mockito的注解,在纯单元测试中由 MockitoExtension 管理。
  • @MockBean 是Spring Boot的注解,用于在集成测试的Spring容器中,将一个Bean替换为Mockito Mock。当你的测试切片(如 @WebMvcTest )需要某个依赖,但你又不希望启动它的真实实现时,就用 @MockBean

4.3 测试事务与数据回滚

集成测试经常涉及数据库操作。为了保证测试之间互不干扰,必须处理好数据清理。

@DataJpaTest
@Transactional // 默认就是开启的,每个测试方法都在事务中,方法结束后自动回滚
class TransactionalTest {

    @Autowired
    private UserRepository repository;

    @Test
    void testWithRollback() {
        User user = new User("Test");
        repository.save(user);
        // 此时在同一个事务内,可以查询到
        assertThat(repository.count()).isEqualTo(1);
    }
    // 测试方法结束后,所有数据库操作回滚,不影响下一个测试
}

// 如果你确实需要提交数据(例如测试事务传播行为),可以禁用事务
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED) // 不在事务中运行
void testWithoutTransaction() {
    // 这里的数据操作会提交到数据库,需要手动清理
}

实操心得 99%的集成测试都应该在事务中运行并自动回滚 。这是保证测试独立性的黄金法则。对于极少数需要测试事务提交后状态的场景,务必在 @AfterEach @AfterAll 方法中编写明确的数据清理逻辑(如 repository.deleteAll() ),并考虑使用独立的测试数据库或Schema。

5. 高级技巧与最佳实践提炼

掌握了基础写法后,一些高级技巧和团队约定能让测试代码的质量再上一个台阶。

5.1 测试代码的可读性与维护性

糟糕的测试代码是项目的债务。遵循以下原则:

  1. 命名即文档 :测试方法名应该清晰地表达场景和预期。使用 should_[行为]_when_[条件] [方法名]_[场景]_[预期] 的格式。例如: shouldReturnEmptyList_whenDatabaseIsEmpty
  2. 单一职责 :一个测试方法只测试一个逻辑分支或场景。不要在一个方法里测完正常流又测异常流。
  3. Given-When-Then结构 :严格遵循三段式,用空行分隔,让代码块意图分明。
  4. 提取通用代码 :将通用的数据准备(如构建一个复杂的对象)提取到 @BeforeEach 方法或静态工厂方法中。但要注意, @BeforeEach 中的代码应该是所有测试 真正需要 的,不要为了复用而复用。
  5. 使用自定义断言(Custom Assertions) :对于复杂对象的断言,可以封装成自定义的AssertJ断言,提高可读性。
    // 自定义断言类
    public class UserAssert extends AbstractAssert<UserAssert, User> {
        public UserAssert hasName(String expectedName) {
            isNotNull();
            if (!actual.getName().equals(expectedName)) {
                failWithMessage("Expected user's name to be <%s> but was <%s>", expectedName, actual.getName());
            }
            return this;
        }
        // 使用
        assertThat(actualUser).hasName("Alice").hasStatus(Status.ACTIVE);
    }
    

5.2 测试替身(Test Double)的深度使用

Mockito提供了丰富的Stubbing和验证功能。

  • 参数匹配器(Argument Matchers) any() , eq() , isNull() , argThat() 等。使用 any() 时要小心,它匹配任何值,包括 null 。如果方法参数是基本类型,要用对应的 anyInt() , anyString() 等。
  • 验证调用次数和超时 times(n) , atLeast(n) , atMost(n) , never() verify(mock, timeout(100)).someMethod() 用于验证异步调用。
  • 按顺序验证(Verification in Order) :如前所述,使用 InOrder 对象。
  • 重置Mock(Reset Mocks) 一般不推荐在测试中使用 reset(mock) 。这通常是测试方法间存在不必要的耦合或测试设计有问题的信号。每个测试方法应该独立地设置其所需的Mock行为。

5.3 常见陷阱与排查技巧

即使经验丰富,也难免踩坑。下面是一些高频问题及解决方案:

问题现象 可能原因 排查与解决
@MockBean 注入的Mock行为不生效 1. 可能被其他配置类中定义的真实Bean覆盖了。
2. 测试类上可能有多个 @SpringBootTest 相关的注解冲突。
1. 检查测试配置,确保没有 @Primary 的真实Bean。
2. 使用 @TestPropertySource @SpringBootTest properties 属性来调整Bean加载顺序。简化测试配置,优先使用测试切片。
集成测试启动奇慢无比 1. 使用了 @SpringBootTest 且未指定 webEnvironment classes ,加载了全量上下文。
2. 应用本身依赖多、初始化复杂。
3. 每次测试都重新构建上下文。
1. 使用测试切片 @WebMvcTest , @DataJpaTest )。
2. 如果必须用全上下文,考虑使用 @SpringBootTest(classes = {YourSpecificConfig.class}) 缩小范围。
3. 利用 @DirtiesContext 控制上下文缓存,但慎用,因为它会破坏测试速度。
事务回滚失效,测试数据污染 1. 测试方法或类上标记了 @Transactional(propagation = Propagation.NOT_SUPPORTED)
2. 在测试方法中手动调用了 entityManager.flush() 且未在事务内。
3. 使用了非事务性的存储(如某些NoSQL客户端)。
1. 确保测试在默认的 @Transactional 环境下运行。
2. 检查数据库表引擎是否支持事务(如MySQL的MyISAM不支持)。
3. 对于无法回滚的操作,在 @AfterEach 中编写清理脚本。
Mockito “Argument(s) are different” 错误 verify when 时,实际传入的参数与预期( eq(value) )不匹配。可能是对象引用不同或字段值不同。 1. 使用 any() 等匹配器放宽验证。
2. 使用 ArgumentCaptor 捕获实际参数进行详细断言。
3. 确保测试数据对象正确实现了 equals() hashCode() 方法,以便 eq() 能正确工作。
测试在CI环境失败,本地却成功 1. 环境差异(数据库、时区、文件路径)。
2. 测试依赖未清理的共享状态(如静态变量)。
3. 并发执行测试导致冲突。
1. 使用内存数据库(H2)并模拟生产环境模式 (如 spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect )。
2. 在 @BeforeEach / @AfterEach 中重置静态状态。
3. 确保测试是独立的,或使用 @ResourceLock 等JUnit5机制控制并发。

一个关于 ArgumentCaptor 的实用例子 : 当你需要验证传递给Mock方法的参数对象内部的具体状态时,它非常有用。

@Test
void createUser_shouldCallRepositoryWithEncryptedPassword() {
    // Arrange
    UserSaveRequest request = new UserSaveRequest("user", "plainPassword");
    ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
    when(userRepository.save(any(User.class))).thenReturn(new User());

    // Act
    userService.createUser(request);

    // Assert
    verify(userRepository).save(userCaptor.capture());
    User savedUser = userCaptor.getValue();
    // 验证保存的用户对象密码是加密后的,而不是明文
    assertThat(savedUser.getPassword()).isNotEqualTo("plainPassword");
    assertThat(passwordEncoder.matches("plainPassword", savedUser.getPassword())).isTrue();
}

6. 测试资源管理与持续集成

最后,让测试套件能在团队和CI/CD流水线中稳定运行,还需要一些工程化考量。

6.1 测试数据管理

对于集成测试,准备测试数据是个麻烦事。除了使用 TestEntityManager 在方法内插入,还有更优雅的方式:

  • 使用 @Sql 注解 :直接在测试类或方法上执行SQL脚本。
    @Test
    @Sql(scripts = "/test-data/create-user.sql")
    @Sql(scripts = "/test-data/cleanup-user.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
    void testWithSqlScript() {
        // 测试逻辑
    }
    
  • 使用测试数据构建器(Test Data Builder) :利用建造者模式或对象母(Object Mother)模式,提供流畅的API来创建复杂的测试对象,避免测试代码中散落着大量的 new setter
  • 使用专门的数据准备工具 :如DbUnit(较老)或通过Flyway/Liquibase维护测试专用的基线数据。

6.2 配置隔离与属性覆盖

测试环境配置必须与开发、生产隔离。

  • application-test.yml :在 src/test/resources 下创建,Spring Boot测试会自动加载 application.yml application-test.yml ,后者优先级更高。在这里配置测试数据库连接、关闭不必要的组件(如Swagger UI扫描、定时任务)。
    # application-test.yml
    spring:
      datasource:
        url: jdbc:h2:mem:testdb;MODE=MySQL
        driver-class-name: org.h2.Driver
      jpa:
        hibernate:
          ddl-auto: create-drop
        show-sql: true
        properties:
          hibernate:
            dialect: org.hibernate.dialect.H2Dialect
    # 关闭缓存、邮件发送等
    spring.cache.type: none
    spring.mail.host: localhost
    
  • @TestPropertySource :在测试类上使用,直接覆盖特定属性,优先级最高。
    @SpringBootTest
    @TestPropertySource(properties = {"spring.datasource.url=jdbc:h2:mem:alternate"})
    class PropertyOverrideTest { ... }
    

6.3 在CI/CD中运行测试

在Jenkins、GitLab CI等环境中,确保测试稳定运行:

  1. 内存管理 :Maven的 mvn test 命令可能需要更多内存,特别是大型项目。在CI脚本中设置 MAVEN_OPTS=-Xmx1g -XX:MaxMetaspaceSize=512m
  2. 跳过非必要测试 :使用Maven的 -DskipITs 跳过集成测试(如果分开),或用 -Dtest=*UnitTest 只运行单元测试。在流水线中,可以分阶段运行:先快速单元测试,通过后再运行较慢的集成测试。
  3. 测试报告 :确保配置了Surefire(单元测试)和Failsafe(集成测试)插件,并生成JUnit格式的XML报告( target/surefire-reports/*.xml ),以便CI工具(如Jenkins)收集和展示。
  4. 并行执行 :JUnit5支持并行运行测试。可以在 src/test/resources/junit-platform.properties 中配置 junit.jupiter.execution.parallel.enabled = true 来加速测试套件运行。但需要仔细处理共享资源(如静态变量、测试数据库),避免竞态条件。

写测试不是负担,而是一种设计工具和安全网。一套好的测试实践,能迫使你写出更松耦合、更可测的代码,在长期迭代中极大地提升开发效率和系统可靠性。从今天起,尝试为你修改的每个Bug或添加的每个新功能,都配上相应的测试,你会发现代码质量和个人信心都会悄然增长。

更多推荐