一、核心概念

Spring Boot 单元测试旨在验证应用中最小可测试单元(通常是单个类或方法)的正确性。它隔离被测代码,避免依赖真实数据库、网络服务等外部系统,确保测试快速、可靠、可重复。

1. 核心组件

  • JUnit 5: 当前主流的 Java 单元测试框架。由 JUnit Platform, JUnit Jupiter, JUnit Vintage 三部分组成。我们主要使用 JUnit Jupiter API。
    • @Test: 标记测试方法。
    • @BeforeEach / @AfterEach: 在每个测试方法前/后执行。
    • @BeforeAll / @AfterAll: 在所有测试方法前/后执行(静态方法)。
    • @DisplayName: 为测试类或方法提供更友好的显示名称。
    • @Disabled: 禁用测试。
    • @ParameterizedTest: 参数化测试。
    • @RepeatedTest: 重复测试。
    • 断言 (Assertions): Assertions.assertEquals(expected, actual), assertTrue(condition), assertThrows(exceptionType, executable) 等。
  • Mockito: 强大的 Java Mocking 框架,用于创建模拟对象 (Mock Objects),模拟依赖的行为,验证交互。
    • @Mock: 创建一个模拟对象。
    • @Spy: 创建一个间谍对象 (Spy),包装真实对象,可以部分模拟其行为。
    • @InjectMocks: 创建一个真实对象,并自动将 @Mock@Spy 注入其字段(通过构造函数、Setter 或字段注入)。
    • when(...).thenReturn(...): 定义模拟对象方法调用的返回值。
    • when(...).thenThrow(...): 定义模拟对象方法调用抛出异常。
    • verify(...): 验证模拟对象的方法是否被调用,以及调用次数等。
    • ArgumentMatchers: any(), eq(), anyString(), argThat(...) 等,用于匹配方法参数。
  • Spring Boot Test: 提供了与 Spring 上下文集成的测试支持。
    • @SpringBootTest: 加载完整的 Spring 应用上下文(或部分),用于集成测试。通常不推荐用于纯粹的单元测试,因为它启动慢。
    • @WebMvcTest: 专注于测试 Spring MVC 控制器 (@Controller, @RestController)。只加载 Web 层相关的 Bean。
    • @DataJpaTest: 专注于测试 JPA Repository。配置了内存数据库(如 H2)和 @Transactional
    • @JsonTest: 专注于测试 JSON 序列化/反序列化(@JsonComponent, Jackson2ObjectMapperBuilder)。
    • @RestClientTest: 专注于测试使用 RestTemplateWebClient 调用外部 REST 服务的组件。
    • @MockBean: 在 Spring 应用上下文中创建或替换一个 Bean 为模拟对象。关键! 用于在 @SpringBootTest, @WebMvcTest 等场景下替换真实依赖。
    • @SpyBean: 在 Spring 应用上下文中创建或替换一个 Bean 为间谍对象。
  • 测试类型区分:
    • 纯单元测试 (Pure Unit Test): 不加载 Spring 上下文,使用 @ExtendWith(MockitoExtension.class)MockitoAnnotations.openMocks(this)。速度最快。
    • 集成测试 (Integration Test): 加载部分或完整的 Spring 上下文(使用 @WebMvcTest, @DataJpaTest, @SpringBootTest)。速度较慢,但测试了组件间的集成。

二、操作步骤(非常详细)

场景设定

测试一个 OrderService,它依赖 OrderRepositoryPaymentService

步骤 1:添加依赖

确保 pom.xmlbuild.gradle 包含必要的测试依赖。

Maven (pom.xml):

<dependencies>
    <!-- Spring Boot Test Starter (包含 JUnit 5, Spring Test, Spring Boot Test) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- 如果需要测试 Web 层,通常已包含在 starter-test 中 -->
    <!-- 如果需要测试 JPA,通常已包含在 starter-test 中 -->
    <!-- 其他业务依赖... -->
</dependencies>

<build>
    <plugins>
        <!-- Surefire 插件用于运行测试 -->
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.0.0-M9</version> <!-- 使用较新版本 -->
        </plugin>
    </plugins>
</build>

Gradle (build.gradle):

dependencies {
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    // 其他业务依赖...
}

test {
    useJUnitPlatform() // 确保使用 JUnit 5 Platform
}

步骤 2:编写被测试的业务代码

// entity/Order.java
package com.example.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String orderNumber;
    private Double amount;
    private String status; // e.g., "PENDING", "PAID", "SHIPPED"

    // Constructors
    public Order() {}
    public Order(String orderNumber, Double amount) {
        this.orderNumber = orderNumber;
        this.amount = amount;
        this.status = "PENDING";
    }

    // Getters and Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getOrderNumber() { return orderNumber; }
    public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; }
    public Double getAmount() { return amount; }
    public void setAmount(Double amount) { this.amount = amount; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
}
// repository/OrderRepository.java
package com.example.repository;

import com.example.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    Order findByOrderNumber(String orderNumber);
}
// service/PaymentService.java
package com.example.service;

import org.springframework.stereotype.Service;

@Service
public class PaymentService {
    // 模拟支付处理
    public boolean processPayment(String orderId, Double amount) {
        // 实际调用支付网关...
        // 这里简化:金额大于0则成功
        return amount != null && amount > 0;
    }
}
// service/OrderService.java
package com.example.service;

import com.example.entity.Order;
import com.example.repository.OrderRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentService paymentService;

    @Autowired
    public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }

    @Transactional
    public Order createOrder(String orderNumber, Double amount) {
        Order order = new Order(orderNumber, amount);
        return orderRepository.save(order);
    }

    @Transactional
    public Order payOrder(String orderNumber) {
        Order order = orderRepository.findByOrderNumber(orderNumber);
        if (order == null) {
            throw new RuntimeException("Order not found: " + orderNumber);
        }
        if (!"PENDING".equals(order.getStatus())) {
            throw new RuntimeException("Order is not in PENDING status");
        }

        boolean paymentSuccess = paymentService.processPayment(order.getOrderNumber(), order.getAmount());
        if (paymentSuccess) {
            order.setStatus("PAID");
            return orderRepository.save(order);
        } else {
            throw new RuntimeException("Payment failed for order: " + orderNumber);
        }
    }

    public Optional<Order> findOrderById(Long id) {
        return orderRepository.findById(id);
    }

    public Optional<Order> findOrderByNumber(String orderNumber) {
        return Optional.ofNullable(orderRepository.findByOrderNumber(orderNumber));
    }
}

步骤 3:编写纯单元测试 (使用 @ExtendWith(MockitoExtension.class))

测试目标: 验证 OrderService.payOrder 方法的逻辑,加载 Spring 上下文。

// service/OrderServiceUnitTest.java
package com.example.service;

import com.example.entity.Order;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*; // 导入 Mockito 注解
import org.springframework.boot.test.mock.mockito.MockBean; // 注意:这里不用 @MockBean
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class) // 启用 Mockito 支持,自动处理 @Mock, @Spy, @InjectMocks
class OrderServiceUnitTest {

    @Mock // 创建 OrderRepository 的模拟对象
    private OrderRepository orderRepository;

    @Mock // 创建 PaymentService 的模拟对象
    private PaymentService paymentService;

    @InjectMocks // 创建真实的 OrderService 实例,并将上面的 @Mock 注入进去
    private OrderService orderService;

    private Order pendingOrder;

    @BeforeEach
    void setUp() {
        // 在每个测试前准备测试数据
        pendingOrder = new Order("ORD-001", 100.0);
        pendingOrder.setStatus("PENDING");
    }

    @Test
    @DisplayName("支付待处理订单成功")
    void payOrder_PendingOrder_Success() {
        // Arrange (准备)
        String orderNumber = "ORD-001";
        // 定义模拟行为:当调用 orderRepository.findByOrderNumber("ORD-001") 时,返回 pendingOrder
        when(orderRepository.findByOrderNumber(orderNumber)).thenReturn(pendingOrder);
        // 定义模拟行为:当调用 paymentService.processPayment(...) 时,返回 true
        when(paymentService.processPayment(orderNumber, 100.0)).thenReturn(true);

        // Act (执行)
        Order result = orderService.payOrder(orderNumber);

        // Assert (断言)
        assertNotNull(result);
        assertEquals("PAID", result.getStatus());
        // 验证 orderRepository.findByOrderNumber 被调用了一次,且参数为 "ORD-001"
        verify(orderRepository, times(1)).findByOrderNumber(eq(orderNumber));
        // 验证 paymentService.processPayment 被调用了一次,且参数匹配
        verify(paymentService, times(1)).processPayment(eq(orderNumber), eq(100.0));
        // 验证 orderRepository.save 被调用了一次(因为状态更新了)
        verify(orderRepository, times(1)).save(any(Order.class));
    }

    @Test
    @DisplayName("支付不存在的订单应抛出异常")
    void payOrder_OrderNotFound_ThrowsException() {
        // Arrange
        String orderNumber = "ORD-999";
        when(orderRepository.findByOrderNumber(orderNumber)).thenReturn(null); // 模拟订单不存在

        // Act & Assert
        // 断言方法会抛出 RuntimeException,且消息包含 "not found"
        RuntimeException thrown = assertThrows(RuntimeException.class, () -> {
            orderService.payOrder(orderNumber);
        });
        assertTrue(thrown.getMessage().contains("not found"));
        // 验证 paymentService 没有被调用
        verify(paymentService, never()).processPayment(anyString(), anyDouble());
    }

    @Test
    @DisplayName("支付已支付订单应抛出异常")
    void payOrder_AlreadyPaidOrder_ThrowsException() {
        // Arrange
        String orderNumber = "ORD-002";
        Order paidOrder = new Order("ORD-002", 50.0);
        paidOrder.setStatus("PAID"); // 已支付状态
        when(orderRepository.findByOrderNumber(orderNumber)).thenReturn(paidOrder);

        // Act & Assert
        RuntimeException thrown = assertThrows(RuntimeException.class, () -> {
            orderService.payOrder(orderNumber);
        });
        assertTrue(thrown.getMessage().contains("not in PENDING status"));
        verify(paymentService, never()).processPayment(anyString(), anyDouble());
    }

    @Test
    @DisplayName("支付时支付服务失败应抛出异常")
    void payOrder_PaymentServiceFails_ThrowsException() {
        // Arrange
        String orderNumber = "ORD-003";
        Order pendingOrder = new Order(orderNumber, 75.0);
        pendingOrder.setStatus("PENDING");
        when(orderRepository.findByOrderNumber(orderNumber)).thenReturn(pendingOrder);
        when(paymentService.processPayment(orderNumber, 75.0)).thenReturn(false); // 模拟支付失败

        // Act & Assert
        RuntimeException thrown = assertThrows(RuntimeException.class, () -> {
            orderService.payOrder(orderNumber);
        });
        assertTrue(thrown.getMessage().contains("Payment failed"));
        // 验证 save 被调用?可能没有,取决于业务逻辑。这里假设状态没变,不保存。
        // 但 findByOrderNumber 和 processPayment 肯定被调用了
        verify(orderRepository, times(1)).findByOrderNumber(eq(orderNumber));
        verify(paymentService, times(1)).processPayment(eq(orderNumber), eq(75.0));
    }

    @Test
    @DisplayName("创建订单应成功保存")
    void createOrder_Success() {
        // Arrange
        String orderNumber = "ORD-NEW";
        Double amount = 200.0;
        Order newOrder = new Order(orderNumber, amount);
        // 模拟保存后返回带有 ID 的订单
        Order savedOrder = new Order(orderNumber, amount);
        savedOrder.setId(1L);
        when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);

        // Act
        Order result = orderService.createOrder(orderNumber, amount);

        // Assert
        assertNotNull(result);
        assertNotNull(result.getId());
        assertEquals(orderNumber, result.getOrderNumber());
        assertEquals(amount, result.getAmount());
        assertEquals("PENDING", result.getStatus());
        verify(orderRepository, times(1)).save(any(Order.class));
    }
}

步骤 4:编写集成测试 (使用 @WebMvcTest 测试 Controller)

测试目标: 测试 OrderController,验证 HTTP 请求/响应,使用 @MockBean 替换 OrderService

// controller/OrderController.java
package com.example.controller;

import com.example.entity.Order;
import com.example.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Optional;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @Autowired
    private OrderService orderService;

    @PostMapping
    public ResponseEntity<Order> createOrder(@RequestParam String orderNumber, @RequestParam Double amount) {
        Order order = orderService.createOrder(orderNumber, amount);
        return ResponseEntity.ok(order);
    }

    @PutMapping("/{orderNumber}/pay")
    public ResponseEntity<Order> payOrder(@PathVariable String orderNumber) {
        Order order = orderService.payOrder(orderNumber);
        return ResponseEntity.ok(order);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Order> getOrderById(@PathVariable Long id) {
        Optional<Order> order = orderService.findOrderById(id);
        return order.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build());
    }

    @GetMapping("/number/{orderNumber}")
    public ResponseEntity<Order> getOrderByNumber(@PathVariable String orderNumber) {
        Optional<Order> order = orderService.findOrderByNumber(orderNumber);
        return order.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build());
    }
}
// controller/OrderControllerWebMvcTest.java
package com.example.controller;

import com.example.entity.Order;
import com.example.service.OrderService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
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 java.util.Optional;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(OrderController.class) // 只加载 OrderController 及其依赖的 Web 层 Bean
class OrderControllerWebMvcTest {

    @Autowired
    private MockMvc mockMvc; // 用于模拟 HTTP 请求

    @MockBean // 在 Spring 上下文中创建 OrderService 的模拟对象,替换真实的 Bean
    private OrderService orderService;

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

    @Test
    @DisplayName("创建订单 - 成功")
    void createOrder_Success() throws Exception {
        // Arrange
        String orderNumber = "ORD-TEST";
        Double amount = 99.99;
        Order savedOrder = new Order(orderNumber, amount);
        savedOrder.setId(1L);
        when(orderService.createOrder(eq(orderNumber), eq(amount))).thenReturn(savedOrder);

        // Act & Assert
        mockMvc.perform(post("/api/orders")
                        .param("orderNumber", orderNumber)
                        .param("amount", amount.toString())
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.orderNumber").value(orderNumber))
                .andExpect(jsonPath("$.amount").value(amount))
                .andExpect(jsonPath("$.status").value("PENDING"));
    }

    @Test
    @DisplayName("支付订单 - 成功")
    void payOrder_Success() throws Exception {
        // Arrange
        String orderNumber = "ORD-001";
        Order paidOrder = new Order(orderNumber, 100.0);
        paidOrder.setId(1L);
        paidOrder.setStatus("PAID");
        when(orderService.payOrder(eq(orderNumber))).thenReturn(paidOrder);

        // Act & Assert
        mockMvc.perform(put("/api/orders/{orderNumber}/pay", orderNumber))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.status").value("PAID"));
    }

    @Test
    @DisplayName("支付订单 - 订单不存在")
    void payOrder_OrderNotFound_ReturnsNotFound() throws Exception {
        // Arrange
        String orderNumber = "ORD-999";
        when(orderService.payOrder(eq(orderNumber))).thenThrow(new RuntimeException("Order not found"));

        // Act & Assert
        mockMvc.perform(put("/api/orders/{orderNumber}/pay", orderNumber))
                .andExpect(status().isNotFound()); // 假设控制器捕获异常并返回 404
        // 注意:实际行为取决于控制器的异常处理机制 (@ControllerAdvice)
    }

    @Test
    @DisplayName("根据ID获取订单 - 存在")
    void getOrderById_Exists() throws Exception {
        // Arrange
        Long orderId = 1L;
        Order order = new Order("ORD-001", 100.0);
        order.setId(orderId);
        when(orderService.findOrderById(eq(orderId))).thenReturn(Optional.of(order));

        // Act & Assert
        mockMvc.perform(get("/api/orders/{id}", orderId))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(orderId.intValue()))
                .andExpect(jsonPath("$.orderNumber").value("ORD-001"));
    }

    @Test
    @DisplayName("根据ID获取订单 - 不存在")
    void getOrderById_NotExists() throws Exception {
        // Arrange
        Long orderId = 999L;
        when(orderService.findOrderById(eq(orderId))).thenReturn(Optional.empty());

        // Act & Assert
        mockMvc.perform(get("/api/orders/{id}", orderId))
                .andExpect(status().isNotFound());
    }
}

步骤 5:编写 @DataJpaTest (可选)

测试目标: 测试 OrderRepository,使用内存数据库。

// repository/OrderRepositoryDataJpaTest.java
package com.example.repository;

import com.example.entity.Order;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.annotation.DirtiesContext;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest // 配置测试 JPA Repository,使用内存数据库 (H2),自动 @Transactional
class OrderRepositoryDataJpaTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    @DirtiesContext // 表示此测试会修改上下文状态,测试后可能需要重置(在 @DataJpaTest 中通常每个测试后回滚)
    void saveAndFindByOrderNumber_ShouldReturnOrder() {
        // Arrange
        Order order = new Order("ORD-JPA-TEST", 50.0);

        // Act
        Order savedOrder = orderRepository.save(order);
        Optional<Order> foundOrder = orderRepository.findByOrderNumber("ORD-JPA-TEST");

        // Assert
        assertThat(savedOrder.getId()).isNotNull();
        assertThat(foundOrder).isPresent();
        assertThat(foundOrder.get().getOrderNumber()).isEqualTo("ORD-JPA-TEST");
    }
}

三、常见错误

  1. MockitoExtension 未启用:

    • 错误: @Mock, @InjectMocks 字段为 null
    • 原因: 忘记添加 @ExtendWith(MockitoExtension.class)
    • 解决: 在测试类上添加 @ExtendWith(MockitoExtension.class)
  2. @MockBean vs @Mock 混淆:

    • 错误:@SpringBootTest@WebMvcTest 中使用 @Mock,导致依赖未被正确替换,仍使用真实 Bean。
    • 原因: @Mock 创建的模拟对象不在 Spring 应用上下文中。@MockBean 才能替换 Spring 容器中的 Bean。
    • 解决: 在需要与 Spring 上下文集成的测试中(@WebMvcTest, @DataJpaTest, @SpringBootTest),使用 @MockBean 替换依赖。在纯单元测试中使用 @Mock
  3. @InjectMocks 注入失败:

    • 错误: @InjectMocks 对象的依赖字段为 null
    • 原因: Mockito 无法通过构造函数、Setter 或字段找到匹配的依赖进行注入(类型不匹配、名字不匹配、构造函数参数过多)。
    • 解决: 确保 @Mock 对象的类型与 @InjectMocks 对象构造函数/Setter/字段的类型完全匹配。检查依赖名称。考虑使用 @Autowired 构造函数注入(推荐)。
  4. MockMvc 未注入:

    • 错误: @Autowired private MockMvc mockMvc;null
    • 原因: 未使用 @WebMvcTest@SpringBootTest
    • 解决: 确保测试类使用了 @WebMvcTest@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
  5. 测试方法未标记 @Test

    • 错误: 测试方法不执行。
    • 解决: 确保测试方法上有 @org.junit.jupiter.api.Test
  6. 断言失败信息不清晰:

    • 错误: assertEquals(expected, actual) 失败时,错误信息可能不够明确。
    • 解决: 使用 assertEquals(expected, actual, "自定义错误消息") 或使用 assertj (assertThat(actual).as("描述").isEqualTo(expected)) 提供更清晰的断言。
  7. @DataJpaTest 未回滚:

    • 错误: 测试间数据污染。
    • 原因: @DataJpaTest 默认是 @Transactional,测试方法结束后会回滚。但如果方法抛出 RuntimeException 且未被捕获,回滚可能失效。
    • 解决: 确保测试逻辑正确。避免在测试中手动 commit@DirtiesContext 可以强制重置上下文(代价高)。
  8. @SpringBootTest 启动过慢:

    • 问题: 使用 @SpringBootTest 导致所有 Bean 加载,测试启动慢。
    • 解决: 优先使用更细粒度的测试切片 (@WebMvcTest, @DataJpaTest)。如果必须用 @SpringBootTest,考虑使用 @Import 只导入必要配置。

四、注意事项

  1. 测试切片选择:
    • 纯单元测试 (@ExtendWith(MockitoExtension.class)): 速度最快,隔离最好。用于测试业务逻辑、Service、Util 类。
    • @WebMvcTest: 测试 Controller 层。速度快,只加载 Web 相关 Bean。用 @MockBean 替换 Service/Repository 依赖。
    • @DataJpaTest: 测试 Repository 层。速度快,使用内存数据库。用 @MockBean 替换 Service 依赖(如果需要)。
    • @SpringBootTest: 测试多个组件集成或需要完整上下文的场景。速度慢。避免用于纯粹的单元测试。
  2. @MockBean 的作用域: @MockBean 在整个测试类的生命周期内有效。如果多个测试方法需要不同的模拟行为,可以在每个方法中使用 when(...).thenReturn(...) 重新定义,或使用 @TestConfiguration 定义不同场景。
  3. @SpyBean 的陷阱: @SpyBean 包装的是真实对象,调用未模拟的方法会执行真实逻辑。可能导致意外的数据库操作或网络调用。谨慎使用。
  4. @Transactional 在测试中: @DataJpaTest@SpringBootTest 默认为测试方法添加 @Transactional,并在方法结束后回滚。这是为了数据隔离。如果测试需要验证数据持久化(如审计字段),可能需要特殊处理。
  5. 测试数据准备: 使用 @BeforeEach 准备共享的测试数据。避免在 @BeforeAll 中准备需要 @Transactional 回滚的数据。
  6. 异常测试: 使用 assertThrows 捕获并断言异常类型和消息。
  7. Mockito.verify 的使用: 主要用于验证交互(如某个方法是否被调用、调用次数)。不要过度使用 verify 来验证内部状态,应优先使用断言 (assert) 验证输出结果。
  8. ArgumentMatchers 的使用: 一旦在 whenverify 中使用了 ArgumentMatchers (如 any(), eq()), 该方法调用中的所有参数都必须使用 ArgumentMatchers。不能混合使用具体值和 any()

五、使用技巧

  1. 使用 assertj (推荐): spring-boot-starter-test 包含 assertj-coreassertThat(actual).isEqualTo(expected).isNotNull() 比原生断言更流畅、信息更丰富。
  2. @DisplayName@Nested: 使用 @DisplayName 提供可读性高的测试名称。使用 @Nested 内部类组织相关测试。
    @DisplayName("OrderService 的 payOrder 方法")
    class PayOrderTests {
        @Test
        @DisplayName("当订单待处理时,应成功支付")
        void shouldPaySuccessfullyWhenPending() { /* ... */ }
    }
    
  3. 参数化测试 (@ParameterizedTest): 测试同一逻辑在不同输入下的行为。
    @ParameterizedTest
    @ValueSource(strings = {"ORD-001", "ORD-002", "ORD-003"})
    void findOrderByNumber_ShouldReturnOrder(String orderNumber) {
        when(orderService.findOrderByNumber(orderNumber)).thenReturn(Optional.of(new Order(orderNumber, 10.0)));
        Optional<Order> result = orderService.findOrderByNumber(orderNumber);
        assertThat(result).isPresent();
    }
    
  4. 测试配置类 (@TestConfiguration): 为特定测试场景提供自定义 Bean 或配置。
    @TestConfiguration
    static class TestConfig {
        @Bean
        @Primary // 优先使用
        public PaymentService mockPaymentService() {
            PaymentService mock = mock(PaymentService.class);
            when(mock.processPayment(anyString(), anyDouble())).thenReturn(true);
            return mock;
        }
    }
    
  5. @Sql 注解: 在测试前/后执行 SQL 脚本,用于准备或清理数据库数据(常用于 @DataJpaTest@SpringBootTest)。
    @Sql(scripts = "/test-data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
    @Sql(scripts = "/clean-data.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
    
  6. TestRestTemplate / WebTestClient: 用于 @SpringBootTest 中测试 REST API。
  7. @DynamicPropertySource: 动态设置测试属性(如数据库 URL、端口),常用于集成测试。
    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    class MyIntegrationTest {
        @DynamicPropertySource
        static void configureProperties(DynamicPropertyRegistry registry) {
            registry.add("app.external.service.url", () -> "http://localhost:" + mockServer.getPort());
        }
    }
    
  8. Mockito.lenient(): 忽略未验证的模拟调用,避免 UnnecessaryStubbingException
  9. 测试覆盖率工具: 使用 JaCoCo 等工具衡量测试覆盖率。

六、最佳实践

  1. 测试命名: 采用 methodName_stateUnderTest_expectedBehavior 格式,如 payOrder_PendingOrder_Success
  2. 遵循 AAA 原则: Arrange (准备), Act (执行), Assert (断言)。结构清晰。
  3. 单一职责: 一个测试方法只测试一个场景或一个行为。
  4. 独立性: 测试方法应相互独立,不依赖执行顺序。使用 @BeforeEach/@AfterEach 管理状态。
  5. 快速: 优先使用纯单元测试和测试切片。避免在单元测试中连接真实数据库或网络。
  6. 可读性: 代码清晰,注释必要。使用 @DisplayNameassertj
  7. 测试失败时提供足够信息: 使用断言消息或 assertjas()
  8. 测试边界条件和异常路径: 不仅测试成功场景,也要测试各种失败和异常情况。
  9. 使用 @MockBean 而非 @Mock 与 Spring 集成: 确保依赖被正确替换。
  10. 避免测试私有方法: 通过测试公共方法的行为来间接覆盖私有方法。如果私有方法逻辑复杂,考虑重构或提取为独立类。
  11. 持续集成 (CI): 在 CI/CD 流程中自动运行测试。

七、性能优化

  1. 选择合适的测试类型:
    • 最大优化: 优先使用纯单元测试 (@ExtendWith(MockitoExtension.class))。它们不加载 Spring 上下文,速度最快。
    • 次优: 使用测试切片 (@WebMvcTest, @DataJpaTest)。它们只加载必要的 Bean,比 @SpringBootTest 快得多。
    • 最后选择: @SpringBootTest。仅在测试需要多个组件集成或完整上下文时使用。
  2. 减少 @SpringBootTest 的启动开销:
    • 使用 @Import 只导入测试所需的配置类,而不是整个应用。
    • 使用 @TestPropertySource@DynamicPropertySource 覆盖属性,避免加载不必要的外部服务配置。
  3. 并行测试执行:
    • JUnit 5:junit-platform.properties 文件中配置:
      junit.jupiter.execution.parallel.enabled=true
      junit.jupiter.execution.parallel.mode.default=concurrent
      
    • 确保测试是线程安全的(无共享可变状态)。
  4. 优化 @DataJpaTest:
    • 确保使用内存数据库 (H2),其速度远快于外部数据库。
    • 避免在 @DataJpaTest 中加载不必要的 @Configuration
  5. 避免在测试中做耗时操作:
    • 不要模拟耗时的网络调用或文件 I/O(除非测试目标就是这些)。
    • 使用 @MockBean@Mock 隔离这些依赖。
  6. 使用 @DirtiesContext 谨慎: 它会重置应用上下文,代价高昂。优先依赖 @Transactional 回滚。
  7. 缓存测试容器 (Testcontainers): 如果使用 Testcontainers,配置 Docker 镜像缓存和复用容器实例以减少启动时间。
  8. 监控测试执行时间: 使用构建工具(Maven Surefire/Gradle Test)的报告功能识别慢测试。

总结: 掌握 Spring Boot 单元测试的关键在于理解不同测试类型(纯单元、切片、集成)的适用场景,并熟练运用 JUnit 5 和 Mockito。优先使用 @ExtendWith(MockitoExtension.class) 进行纯单元测试,利用 @Mock@InjectMocks 隔离依赖。在需要测试 Spring 特定层(Web、JPA)时,使用对应的测试切片(@WebMvcTest, @DataJpaTest)并用 @MockBean 替换外部依赖。遵循 AAA 原则、命名规范和最佳实践,编写快速、可靠、可维护的测试,是保障 Spring Boot 应用质量的基石。