1. 项目概述:为什么测试是Java开发的“安全带”?

干了这么多年Java开发,我越来越觉得,写代码这事儿,就跟开车上路一样。你车技再好,不系安全带,一次意外就可能让你前功尽弃。而单元测试和集成测试,就是我们程序员写代码时必须系上的“安全带”。这个“Java单元测试与集成测试实战”项目,说白了,就是手把手教你如何给Java项目系上这条可靠的安全带,确保你的代码在上线前是健壮的、可预测的,而不是一个随时可能爆炸的“定时炸弹”。

很多新手,甚至一些工作了几年的朋友,对测试的态度往往是“项目紧,先实现功能,测试以后再说”。结果就是,代码越堆越多,牵一发而动全身,改一个小功能可能引发十个Bug,最后陷入“救火-修复-引入新Bug”的恶性循环,加班加到怀疑人生。单元测试和集成测试,正是为了打破这个循环而生的。它们不是QA(质量保证)团队的专属,而是每个开发者的核心技能和职业素养。通过这个实战指南,你将掌握如何用JUnit、Mockito、TestContainers等主流工具,构建一个从代码块到服务接口的完整测试防护网,让你写的每一行代码都更有底气,让重构和迭代不再是一件提心吊胆的事。

2. 测试体系全景:单元、集成与其他测试的定位与关系

在动手之前,我们必须先理清测试的“家族谱”。测试不是铁板一块,它是一个有层次、有分工的体系。理解每种测试的职责和边界,是高效实践的前提。

2.1 测试金字塔:构建高效测试策略的基石

测试金字塔是一个经典模型,它形象地说明了不同层级测试的理想数量比例。自底向上分别是:

  • 单元测试(Unit Tests) :金字塔的塔基,数量最多。它针对最小的可测试单元(通常是单个类或方法)进行隔离测试,速度极快(毫秒级),目的是验证代码逻辑的正确性。
  • 集成测试(Integration Tests) :金字塔的中间层,数量适中。它测试多个模块或组件之间的交互是否正确,例如服务与数据库、服务与外部API的集成。速度比单元测试慢,但比UI测试快。
  • 端到端测试(E2E Tests) / UI测试 :金字塔的塔尖,数量最少。它模拟真实用户操作,测试整个应用流程。速度最慢,也最脆弱(UI变动易导致失败)。

我们的实战重点在塔基和中间层。一个健康的项目,应该拥有大量快速的单元测试,一定数量的集成测试,以及少量的端到端测试。盲目增加高层测试而忽视底层测试,会导致测试套件运行缓慢、维护成本高昂。

2.2 单元测试 vs. 集成测试:核心区别与选用场景

这是最容易混淆的一对概念。我们可以用一个简单的“用户注册”功能来区分:

  • 单元测试(测“点”)

    • 目标 :验证 UserService.register(UserDto dto) 方法内部的业务逻辑。例如,密码加密逻辑、用户名重复校验逻辑、数据组装逻辑。
    • 方式 完全隔离 。使用Mockito等工具,将 UserRepository (数据库访问)、 EmailService (邮件服务)等依赖全部“模拟”(Mock)出来。测试只关心 UserService 自身的代码。
    • 断言 :验证方法是否按预期调用了 repository.save() (传入的参数是否正确),或者当用户名重复时是否抛出了特定的业务异常。
    • 场景 :所有核心业务逻辑、工具类、算法实现。
  • 集成测试(测“线”)

    • 目标 :验证 UserService 与真实的 UserRepository (连接真实或测试数据库)协作是否正常,或者 UserController 的HTTP接口能否正确处理请求并返回响应。
    • 方式 部分集成 。允许与真实的数据库、内存消息队列、或其他内部服务交互,但可能仍会Mock掉像第三方支付网关、短信服务这类不稳定或收费的外部依赖。
    • 断言 :调用注册接口后,数据库中是否真的多了一条用户记录;返回的HTTP状态码和JSON格式是否正确。
    • 场景 :数据库操作、缓存集成、内部服务间HTTP调用、消息队列的生产与消费。

注意 :单元测试中“单元”的界定有时会有争议。一个类是一个单元,一个方法也是一个单元。我个人的实践是,以一个“类”为单元进行测试组织,但Mock掉其所有外部依赖,确保测试的纯粹性和速度。

2.3 其他相关测试概念:厘清边界

为了避免概念泛化,我们简要提及其他常见测试:

  • 功能测试 :通常等同于集成测试或端到端测试,侧重于验证功能是否满足需求,不关心内部实现。
  • 系统测试 :在集成测试之后,将整个软件系统作为一个整体进行测试,验证其是否满足规格要求。
  • 验收测试 :通常由产品或业务人员主导,验证软件是否满足用户需求和业务目标。
  • 性能测试/压力测试 :属于专项测试,评估系统在高负载下的表现,不在本实战主要讨论范围。

理解这些概念后,我们就能清晰地规划: 用单元测试守护每一个逻辑细节,用集成测试验证模块间的协作通路。

3. 单元测试实战:从JUnit 5到Mockito的深度应用

单元测试是我们的第一道,也是最重要的防线。现代Java单元测试几乎离不开JUnit 5和Mockito这对“黄金搭档”。

3.1 环境搭建与JUnit 5核心注解剖析

假设我们使用Maven构建Spring Boot项目。首先在 pom.xml 中引入依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

spring-boot-starter-test 已经包含了JUnit 5、Mockito、AssertJ、Hamcrest等我们所需的大部分库。

JUnit 5的注解是测试类的骨架:

  • @Test :标记一个方法为测试方法。这是核心。
  • @BeforeEach / @AfterEach :在每个 @Test 方法 之前 / 之后 运行。常用于初始化测试数据或清理资源。 踩坑提示 :如果 @BeforeEach 中初始化了被多个测试方法共享的成员变量,务必注意测试的隔离性,避免一个测试修改了数据影响另一个。最佳实践是每个测试方法都自己准备数据。
  • @BeforeAll / @AfterAll :在所有测试方法 之前 / 之后 运行 一次 。方法必须是 static 。适合做耗时的全局初始化,如启动嵌入式数据库。
  • @DisplayName :为测试类或方法设置一个易读的名称,会在测试报告中显示。强烈建议使用,提高可读性。
  • @Nested :用于创建嵌套的测试类,更好地组织具有共同前提条件的测试。
  • @ParameterizedTest :参数化测试,配合 @ValueSource @CsvSource 等使用,可以用多组数据运行同一个测试逻辑,极大减少重复代码。

3.2 测试替身(Mock/Stub/Spy)与Mockito实战

单元测试的核心思想是“隔离”。我们通过创建依赖对象的“替身”来实现。Mockito是最流行的框架。

  • Mock :创建一个完全虚拟的对象。你可以设定它的行为(当调用方法A时,返回结果B)。默认情况下,Mock对象的方法返回 null 、空集合或0等。
  • Spy :创建一个真实对象的“间谍”。默认调用真实方法,但你可以选择性地“存根”(Stub)其中某些方法的行为。
  • Stub :打桩,是设定Mock或Spy对象行为的过程。

实战场景 :测试一个 OrderService placeOrder 方法,该方法内部会调用 InventoryService.checkStock() PaymentService.process()

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.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(MockitoExtension.class) // 启用Mockito
public class OrderServiceTest {

    @Mock
    private InventoryService inventoryService; // 模拟库存服务
    @Mock
    private PaymentService paymentService; // 模拟支付服务

    @InjectMocks
    private OrderService orderService; // 将被测服务注入模拟的依赖

    @Test
    @DisplayName("当库存充足且支付成功时,应成功创建订单")
    void placeOrder_Success() {
        // 1. 准备测试数据
        OrderRequest request = new OrderRequest("item-123", 2);
        String transactionId = "txn_001";

        // 2. 定义模拟对象的行为(打桩)
        when(inventoryService.checkStock("item-123", 2)).thenReturn(true);
        when(paymentService.process(any(BigDecimal.class))).thenReturn(transactionId);

        // 3. 执行被测方法
        OrderResult result = orderService.placeOrder(request);

        // 4. 验证结果和行为
        assertNotNull(result);
        assertTrue(result.isSuccess());
        assertEquals(transactionId, result.getTransactionId());

        // 验证交互:placeOrder方法是否按预期调用了依赖
        verify(inventoryService, times(1)).checkStock("item-123", 2);
        verify(paymentService, times(1)).process(any(BigDecimal.class));
        // 验证某个方法从未被调用
        verify(inventoryService, never()).alertLowStock(anyString());
    }

    @Test
    @DisplayName("当库存不足时,应抛出业务异常且不调用支付")
    void placeOrder_Fail_OutOfStock() {
        OrderRequest request = new OrderRequest("item-456", 10);
        when(inventoryService.checkStock("item-456", 10)).thenReturn(false); // 模拟库存不足

        // 断言会抛出特定异常
        BusinessException exception = assertThrows(BusinessException.class, () -> {
            orderService.placeOrder(request);
        });
        assertEquals("商品库存不足", exception.getMessage());

        // 验证支付服务没有被调用
        verify(paymentService, never()).process(any());
    }
}

实操心得

  1. Given-When-Then模式 :这是组织测试代码的黄金法则。 Given 部分准备数据和Mock行为, When 部分执行被测方法, Then 部分进行断言和验证。上面的代码注释清晰地体现了这一点,这能让测试逻辑一目了然。
  2. verify 的使用:不要过度验证。只验证那些与被测方法业务逻辑直接相关的、重要的交互。过度验证会导致测试脆弱,一旦内部实现细节(如调用顺序、无关紧要的日志方法)改变,测试就会失败,尽管功能可能依然正确。
  3. any() 等参数匹配器:当不关心具体参数值时使用。但要小心,如果方法重载, any() 可能匹配不到你期望的方法。

3.3 断言库的选择:JUnit 5 vs. AssertJ

JUnit 5自带的 Assertions 类已经够用,但AssertJ提供了流式API,断言更强大、可读性更高。

// JUnit 5 断言
assertEquals(expected, actual);
assertTrue(collection.isEmpty());
assertThrows(SomeException.class, () -> service.doSomething());

// AssertJ 断言 (更推荐)
import static org.assertj.core.api.Assertions.*;
assertThat(actual).isEqualTo(expected);
assertThat(collection).isEmpty();
assertThatThrownBy(() -> service.doSomething())
    .isInstanceOf(SomeException.class)
    .hasMessageContaining("expected error");
// 链式调用,支持集合、对象属性、字符串等多种断言
assertThat(userList)
    .hasSize(3)
    .extracting(User::getName)
    .containsExactly("Alice", "Bob", "Charlie");

建议 :在新项目中直接使用AssertJ,它的错误信息更友好,能极大提升调试效率。

4. 集成测试实战:连接真实组件的测试艺术

集成测试验证的是协作。在Spring生态中,我们主要测试:1) 数据层与数据库;2) Web层与控制器的HTTP接口。

4.1 数据层集成测试:@DataJpaTest与Testcontainers

对于Spring Data JPA的Repository测试,Spring Boot提供了 @DataJpaTest 注解。它会自动配置一个内存数据库(如H2),只初始化JPA相关的组件。

import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.beans.factory.annotation.Autowired;

@DataJpaTest // 关键注解
public class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    @DisplayName("根据邮箱查找用户 - 集成测试")
    void findByEmail_ShouldReturnUser() {
        // Given: 直接操作Repository保存数据到(内存)数据库
        User savedUser = new User("test@example.com", "encodedPassword");
        userRepository.save(savedUser);

        // When: 执行查询方法
        Optional<User> found = userRepository.findByEmail("test@example.com");

        // Then: 验证数据库交互结果
        assertThat(found).isPresent();
        assertThat(found.get().getEmail()).isEqualTo("test@example.com");
    }
}

但这里有个大坑 @DataJpaTest 默认使用H2内存数据库。H2与MySQL、PostgreSQL等生产数据库存在语法和功能差异(如窗口函数、特定JSON函数、约束行为),可能导致测试通过,上线失败。

解决方案:使用Testcontainers 。它能在Docker容器中启动一个真实的生产数据库进行测试。

import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers // 启用Testcontainers支持
@DataJpaTest
// 关键:覆盖数据源配置,指向Testcontainer启动的数据库
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class UserRepositoryWithRealDbTest {

    // 定义一个共享的PostgreSQL容器
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    // 在Spring上下文初始化前,动态设置数据库连接属性
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private UserRepository userRepository;

    @Test
    void testWithRealPostgres() {
        // 测试逻辑与之前类似,但这次运行在真实的PostgreSQL中
        User user = new User("real@test.com", "pwd");
        userRepository.save(user);
        assertThat(userRepository.findByEmail("real@test.com")).isPresent();
    }
}

实操心得 :虽然Testcontainers测试比内存数据库慢(需要拉取镜像、启动容器),但它提供了无与伦比的真实性。建议在CI/CD流水线中运行这类集成测试,本地开发时可根据需要选择性地运行。对于复杂的SQL或数据库特性相关的逻辑,必须使用Testcontainers。

4.2 Web层集成测试:@WebMvcTest与MockMvc

对于Controller的测试,我们使用 @WebMvcTest 。它会切片加载Web层相关的配置,默认会Mock掉Service层的Bean,让我们可以专注于HTTP请求和响应的测试。

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import com.fasterxml.jackson.databind.ObjectMapper;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(UserController.class) // 只加载UserController相关的Web配置
public class UserControllerTest {

    @Autowired
    private MockMvc mockMvc; // 模拟HTTP请求的核心工具

    @Autowired
    private ObjectMapper objectMapper; // JSON序列化/反序列化

    @MockBean // 模拟Service,因为@WebMvcTest不会加载@Service
    private UserService userService;

    @Test
    @DisplayName("POST /api/users - 创建用户成功")
    void createUser_ShouldReturnCreated() throws Exception {
        UserDto requestDto = new UserDto("newuser@example.com", "password123");
        UserResponse response = new UserResponse(1L, "newuser@example.com");
        when(userService.createUser(any(UserDto.class))).thenReturn(response);

        mockMvc.perform(post("/api/users") // 模拟POST请求
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(requestDto)))
                .andExpect(status().isCreated()) // 断言HTTP状态码为201
                .andExpect(jsonPath("$.id").value(1L)) // 断言JSON响应体
                .andExpect(jsonPath("$.email").value("newuser@example.com"));
    }

    @Test
    @DisplayName("GET /api/users/{id} - 用户不存在时返回404")
    void getUserById_ShouldReturn404_WhenUserNotFound() throws Exception {
        when(userService.getUserById(999L)).thenThrow(new UserNotFoundException("用户不存在"));

        mockMvc.perform(get("/api/users/{id}", 999L))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.message").value("用户不存在"));
    }
}

注意事项

  1. @MockBean 是Spring提供的注解,用于在Spring的 ApplicationContext 中Mock一个Bean。它与Mockito的 @Mock 不同,后者需要在 @ExtendWith(MockitoExtension.class) 下使用,且不涉及Spring容器。
  2. MockMvc 提供了非常强大的请求构建和结果验证能力,可以测试请求头、参数、会话、异常处理等几乎所有Web层特性。
  3. 对于RESTful API,务必测试边界情况和错误场景(如无效输入、资源不存在、权限不足等),这比只测试“happy path”更重要。

4.3 全应用集成测试:@SpringBootTest

当需要测试多个层级(如从Controller到Repository)的完整流程时,可以使用 @SpringBootTest 。它会启动一个完整的、但可能是轻量级的应用上下文。

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

// 使用随机端口,避免冲突
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public class UserIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        // ... 同上
    }

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

    @Test
    void fullIntegration_CreateAndRetrieveUser() {
        // 1. 创建用户
        UserDto request = new UserDto("int-test@example.com", "pass");
        ResponseEntity<UserResponse> createResponse = restTemplate.postForEntity(
                "/api/users", request, UserResponse.class);
        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        Long userId = createResponse.getBody().getId();

        // 2. 查询用户
        ResponseEntity<UserResponse> getResponse = restTemplate.getForEntity(
                "/api/users/" + userId, UserResponse.class);
        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(getResponse.getBody().getEmail()).isEqualTo("int-test@example.com");

        // 3. 验证数据库(可通过注入Repository进行额外断言)
        // ...
    }
}

使用建议 @SpringBootTest 启动较慢,应谨慎使用。通常用于测试涉及复杂工作流、事务管理、安全配置等需要完整上下文的场景。在CI流水线中,可以将其作为“集成测试套件”的一部分运行。

5. 测试代码的质量与可维护性

写出能运行的测试只是第一步,写出 的测试才能长期受益。糟糕的测试比没有测试更可怕,因为它会带来虚假的安全感和高昂的维护成本。

5.1 好测试的FIRST原则

  • F - Fast(快速) :测试必须快。单元测试应在毫秒级,集成测试也尽量控制在秒级。慢的测试会导致开发者不愿意运行。
  • I - Independent/Isolated(独立/隔离) :测试之间不应有依赖,可以以任何顺序运行。避免使用共享的静态变量或未清理的数据库状态。
  • R - Repeatable(可重复) :在任何环境(本地、CI服务器)中运行都能得到相同的结果。不能依赖网络、时间、随机数等外部不确定因素。
  • S - Self-Validating(自验证) :测试必须能自动判断通过还是失败,无需人工检查日志或输出。
  • T - Timely(及时) :最好在编写生产代码的同时或之前编写测试(TDD)。

5.2 测试命名与结构的最佳实践

  • 命名 :使用 方法名_测试场景_预期结果 的格式。例如: placeOrder_WhenStockInsufficient_ThrowsException @DisplayName 可以用更自然的中文或英文句子描述。
  • 结构 :严格遵守 Arrange-Act-Assert (AAA) Given-When-Then 模式。用空行将三个部分隔开,让代码结构清晰。
  • 单一职责 :一个测试方法只测试一个行为或场景。不要在一个方法里验证多种条件。
  • DRY(Don‘t Repeat Yourself)原则 :将通用的准备代码(如创建测试对象)提取到 @BeforeEach 方法或工具类中。但要注意,过度提取可能会降低测试的可读性,因为读者需要在多个地方跳转才能理解测试上下文。

5.3 测试数据管理:@Sql与测试数据工厂

对于集成测试,准备和清理测试数据是关键。

  • @Sql 注解 :Spring Test提供的注解,用于在测试方法前后执行指定的SQL脚本。
    @Test
    @Sql(scripts = "/insert-test-users.sql") // 测试前执行
    @Sql(scripts = "/cleanup-users.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) // 测试后执行
    void testWithSqlScript() {
        // 测试逻辑...
    }
    
  • 测试数据工厂(Test Data Factory) :创建一个专门生成测试对象的工厂类。这比在多个测试中重复构建对象更好,也便于统一修改。
    public class TestUserFactory {
        public static User createValidUser() {
            return User.builder()
                    .email("user@test.com")
                    .passwordHash("encodedHash")
                    .status(Status.ACTIVE)
                    .build();
        }
        public static UserDto createValidUserDto() { ... }
        // 可以创建各种边界情况的工厂方法
        public static User createUserWithLongEmail() { ... }
    }
    

5.4 测试覆盖率:目标与陷阱

Jacoco是常用的Java代码覆盖率工具。覆盖率是一个重要的度量指标,但切勿盲目追求高数字。

  • 行覆盖率 :至少应达到70%-80%,这是基本要求。
  • 分支覆盖率 :更重要,它衡量条件语句(if/else, switch)是否都被测试到。
  • 陷阱
    1. 覆盖率高 != 质量高 :测试可能覆盖了所有行,但断言(Assertion)很弱,没有验证核心逻辑。
    2. 不要为了覆盖率而写测试 :有些代码(如简单的Getter/Setter、自动生成的Mapper方法)不值得测试。有些复杂的私有方法,应该通过测试其公有调用来间接覆盖。
    3. 覆盖率是发现未测代码的工具,不是终极目标 :它的主要价值是帮你发现哪些代码完全没有被测试到,而不是炫耀一个百分比。

6. 持续集成中的测试策略与常见问题排查

测试最终要融入到开发流程中,持续集成(CI)是保证测试持续运行的关键环节。

6.1 在CI流水线中组织测试阶段

一个典型的CI流水线(如Jenkins、GitLab CI、GitHub Actions)应包含以下测试阶段:

  1. 代码质量检查 :先运行SpotBugs、Checkstyle、PMD等静态代码分析。
  2. 快速单元测试 :运行所有单元测试。这阶段必须快,失败应立即反馈。
  3. 集成测试 :运行集成测试套件。这阶段可以稍慢,但应并行化以缩短总时间。使用Testcontainers时,确保CI环境安装了Docker。
  4. 端到端测试 (可选):如果项目有,可以在此阶段运行。
  5. 构建与部署 :所有测试通过后,才进行打包和部署到测试环境。

配置示例(GitHub Actions)

name: Java CI
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15-alpine
        env: ...
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with: { java-version: '17' }
      - name: Run Unit Tests
        run: mvn test
      - name: Run Integration Tests
        run: mvn verify -DskipUnitTests=false -DskipITs=false
        # 通常通过Maven的failsafe插件来区分单元测试和集成测试

6.2 常见问题与排查技巧实录

即使经验丰富,写测试也会遇到各种坑。下面是一些高频问题及解决思路:

问题现象 可能原因 排查与解决思路
@MockBean 注入的Bean为null 测试类使用了错误的测试切片注解或Runner。 确保使用 @WebMvcTest @DataJpaTest 等切片注解,或使用 @SpringBootTest 。检查是否错误地混用了 @ExtendWith(MockitoExtension.class) @SpringBootTest
集成测试时数据库连接失败 配置错误,或Testcontainers容器未启动。 1. 检查 application-test.properties 配置。2. 确保CI环境支持Docker。3. 在 @Testcontainers 类中,容器字段需为 static 。4. 查看日志,确认容器JDBC URL是否正确注入。
测试在CI上通过,本地失败(或反之) 环境差异:时区、文件路径、端口占用、数据库数据残留。 1. 使用一致的数据库 :强烈推荐Testcontainers。2. 清理测试数据 :确保每个测试独立,使用 @Transactional (小心回滚行为)或手动清理。3. 固定随机种子 :如果测试涉及随机数。
Mockito: “UnnecessaryStubbingException” 测试中定义了Mock行为,但实际执行路径并未用到。 这是Mockito的严格检查,有助于保持测试简洁。使用 @MockitoSettings(strictness = Strictness.LENIENT) 放宽检查,或更好的方法是 重构测试 ,移除无用的 when() 语句。
测试运行速度突然变慢 1. 构建了完整的Spring上下文( @SpringBootTest )。
2. 测试未正确隔离,启动了不必要的服务或容器。
3. 单个测试方法做了太多事。
1. 优先使用切片测试( @WebMvcTest , @DataJpaTest )。
2. 审视集成测试范围,是否可以用单元测试替代部分场景。
3. 利用 @BeforeAll 代替重复的 @BeforeEach 初始化(如果安全)。
4. 并行运行测试(JUnit 5支持)。
涉及时间(如 new Date() )的测试不稳定 测试依赖于当前时间,不同时间运行结果可能不同。 使用“时间旅行”工具 :如 java.time.Clock 或第三方库(如 org.mockito:mockito-inline 可以Mock静态方法)。将时间源作为依赖注入,在测试中固定它。
事务回滚相关问题 在集成测试中使用了 @Transactional ,导致测试后数据自动回滚,但某些验证(如异步操作、独立数据库连接)需要看到提交的数据。 理解 @Transactional 在测试中的默认行为(默认回滚)。对于需要提交的测试,可以使用 @Commit 注解,或者手动管理事务,并在测试后清理数据。

我个人最深刻的体会是 :测试不是负担,而是投资。初期投入时间编写高质量的测试,会在代码重构、功能扩展、新人接手、定位线上问题时,十倍百倍地回报你。它让你有勇气去修改任何一段代码,因为你知道有测试网在兜底。开始可能觉得繁琐,但一旦形成习惯,它就会成为你开发流程中不可或缺的、令人安心的一部分。从今天起,尝试为你修复的下一个Bug或写的新功能,先补上一个测试吧,你会感受到它带来的变化。

更多推荐