一、核心概念
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
: 专注于测试使用RestTemplate
或WebClient
调用外部 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
)。速度较慢,但测试了组件间的集成。
- 纯单元测试 (Pure Unit Test): 不加载 Spring 上下文,使用
二、操作步骤(非常详细)
场景设定
测试一个 OrderService
,它依赖 OrderRepository
和 PaymentService
。
步骤 1:添加依赖
确保 pom.xml
或 build.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");
}
}
三、常见错误
MockitoExtension
未启用:- 错误:
@Mock
,@InjectMocks
字段为null
。 - 原因: 忘记添加
@ExtendWith(MockitoExtension.class)
。 - 解决: 在测试类上添加
@ExtendWith(MockitoExtension.class)
。
- 错误:
@MockBean
vs@Mock
混淆:- 错误: 在
@SpringBootTest
或@WebMvcTest
中使用@Mock
,导致依赖未被正确替换,仍使用真实 Bean。 - 原因:
@Mock
创建的模拟对象不在 Spring 应用上下文中。@MockBean
才能替换 Spring 容器中的 Bean。 - 解决: 在需要与 Spring 上下文集成的测试中(
@WebMvcTest
,@DataJpaTest
,@SpringBootTest
),使用@MockBean
替换依赖。在纯单元测试中使用@Mock
。
- 错误: 在
@InjectMocks
注入失败:- 错误:
@InjectMocks
对象的依赖字段为null
。 - 原因: Mockito 无法通过构造函数、Setter 或字段找到匹配的依赖进行注入(类型不匹配、名字不匹配、构造函数参数过多)。
- 解决: 确保
@Mock
对象的类型与@InjectMocks
对象构造函数/Setter/字段的类型完全匹配。检查依赖名称。考虑使用@Autowired
构造函数注入(推荐)。
- 错误:
MockMvc
未注入:- 错误:
@Autowired private MockMvc mockMvc;
为null
。 - 原因: 未使用
@WebMvcTest
或@SpringBootTest
。 - 解决: 确保测试类使用了
@WebMvcTest
或@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
。
- 错误:
测试方法未标记
@Test
:- 错误: 测试方法不执行。
- 解决: 确保测试方法上有
@org.junit.jupiter.api.Test
。
断言失败信息不清晰:
- 错误:
assertEquals(expected, actual)
失败时,错误信息可能不够明确。 - 解决: 使用
assertEquals(expected, actual, "自定义错误消息")
或使用assertj
(assertThat(actual).as("描述").isEqualTo(expected)
) 提供更清晰的断言。
- 错误:
@DataJpaTest
未回滚:- 错误: 测试间数据污染。
- 原因:
@DataJpaTest
默认是@Transactional
,测试方法结束后会回滚。但如果方法抛出RuntimeException
且未被捕获,回滚可能失效。 - 解决: 确保测试逻辑正确。避免在测试中手动
commit
。@DirtiesContext
可以强制重置上下文(代价高)。
@SpringBootTest
启动过慢:- 问题: 使用
@SpringBootTest
导致所有 Bean 加载,测试启动慢。 - 解决: 优先使用更细粒度的测试切片 (
@WebMvcTest
,@DataJpaTest
)。如果必须用@SpringBootTest
,考虑使用@Import
只导入必要配置。
- 问题: 使用
四、注意事项
- 测试切片选择:
- 纯单元测试 (
@ExtendWith(MockitoExtension.class)
): 速度最快,隔离最好。用于测试业务逻辑、Service、Util 类。 @WebMvcTest
: 测试 Controller 层。速度快,只加载 Web 相关 Bean。用@MockBean
替换 Service/Repository 依赖。@DataJpaTest
: 测试 Repository 层。速度快,使用内存数据库。用@MockBean
替换 Service 依赖(如果需要)。@SpringBootTest
: 测试多个组件集成或需要完整上下文的场景。速度慢。避免用于纯粹的单元测试。
- 纯单元测试 (
@MockBean
的作用域:@MockBean
在整个测试类的生命周期内有效。如果多个测试方法需要不同的模拟行为,可以在每个方法中使用when(...).thenReturn(...)
重新定义,或使用@TestConfiguration
定义不同场景。@SpyBean
的陷阱:@SpyBean
包装的是真实对象,调用未模拟的方法会执行真实逻辑。可能导致意外的数据库操作或网络调用。谨慎使用。@Transactional
在测试中:@DataJpaTest
和@SpringBootTest
默认为测试方法添加@Transactional
,并在方法结束后回滚。这是为了数据隔离。如果测试需要验证数据持久化(如审计字段),可能需要特殊处理。- 测试数据准备: 使用
@BeforeEach
准备共享的测试数据。避免在@BeforeAll
中准备需要@Transactional
回滚的数据。 - 异常测试: 使用
assertThrows
捕获并断言异常类型和消息。 Mockito.verify
的使用: 主要用于验证交互(如某个方法是否被调用、调用次数)。不要过度使用verify
来验证内部状态,应优先使用断言 (assert
) 验证输出结果。ArgumentMatchers
的使用: 一旦在when
或verify
中使用了ArgumentMatchers
(如any()
,eq()
), 该方法调用中的所有参数都必须使用ArgumentMatchers
。不能混合使用具体值和any()
。
五、使用技巧
- 使用
assertj
(推荐):spring-boot-starter-test
包含assertj-core
。assertThat(actual).isEqualTo(expected).isNotNull()
比原生断言更流畅、信息更丰富。 @DisplayName
和@Nested
: 使用@DisplayName
提供可读性高的测试名称。使用@Nested
内部类组织相关测试。@DisplayName("OrderService 的 payOrder 方法") class PayOrderTests { @Test @DisplayName("当订单待处理时,应成功支付") void shouldPaySuccessfullyWhenPending() { /* ... */ } }
- 参数化测试 (
@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(); }
- 测试配置类 (
@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; } }
@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)
TestRestTemplate
/WebTestClient
: 用于@SpringBootTest
中测试 REST API。@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()); } }
Mockito.lenient()
: 忽略未验证的模拟调用,避免UnnecessaryStubbingException
。- 测试覆盖率工具: 使用 JaCoCo 等工具衡量测试覆盖率。
六、最佳实践
- 测试命名: 采用
methodName_stateUnderTest_expectedBehavior
格式,如payOrder_PendingOrder_Success
。 - 遵循 AAA 原则: Arrange (准备), Act (执行), Assert (断言)。结构清晰。
- 单一职责: 一个测试方法只测试一个场景或一个行为。
- 独立性: 测试方法应相互独立,不依赖执行顺序。使用
@BeforeEach
/@AfterEach
管理状态。 - 快速: 优先使用纯单元测试和测试切片。避免在单元测试中连接真实数据库或网络。
- 可读性: 代码清晰,注释必要。使用
@DisplayName
和assertj
。 - 测试失败时提供足够信息: 使用断言消息或
assertj
的as()
。 - 测试边界条件和异常路径: 不仅测试成功场景,也要测试各种失败和异常情况。
- 使用
@MockBean
而非@Mock
与 Spring 集成: 确保依赖被正确替换。 - 避免测试私有方法: 通过测试公共方法的行为来间接覆盖私有方法。如果私有方法逻辑复杂,考虑重构或提取为独立类。
- 持续集成 (CI): 在 CI/CD 流程中自动运行测试。
七、性能优化
- 选择合适的测试类型:
- 最大优化: 优先使用纯单元测试 (
@ExtendWith(MockitoExtension.class)
)。它们不加载 Spring 上下文,速度最快。 - 次优: 使用测试切片 (
@WebMvcTest
,@DataJpaTest
)。它们只加载必要的 Bean,比@SpringBootTest
快得多。 - 最后选择:
@SpringBootTest
。仅在测试需要多个组件集成或完整上下文时使用。
- 最大优化: 优先使用纯单元测试 (
- 减少
@SpringBootTest
的启动开销:- 使用
@Import
只导入测试所需的配置类,而不是整个应用。 - 使用
@TestPropertySource
或@DynamicPropertySource
覆盖属性,避免加载不必要的外部服务配置。
- 使用
- 并行测试执行:
- JUnit 5: 在
junit-platform.properties
文件中配置:junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=concurrent
- 确保测试是线程安全的(无共享可变状态)。
- JUnit 5: 在
- 优化
@DataJpaTest
:- 确保使用内存数据库 (H2),其速度远快于外部数据库。
- 避免在
@DataJpaTest
中加载不必要的@Configuration
。
- 避免在测试中做耗时操作:
- 不要模拟耗时的网络调用或文件 I/O(除非测试目标就是这些)。
- 使用
@MockBean
或@Mock
隔离这些依赖。
- 使用
@DirtiesContext
谨慎: 它会重置应用上下文,代价高昂。优先依赖@Transactional
回滚。 - 缓存测试容器 (Testcontainers): 如果使用 Testcontainers,配置 Docker 镜像缓存和复用容器实例以减少启动时间。
- 监控测试执行时间: 使用构建工具(Maven Surefire/Gradle Test)的报告功能识别慢测试。
总结: 掌握 Spring Boot 单元测试的关键在于理解不同测试类型(纯单元、切片、集成)的适用场景,并熟练运用 JUnit 5 和 Mockito。优先使用 @ExtendWith(MockitoExtension.class)
进行纯单元测试,利用 @Mock
和 @InjectMocks
隔离依赖。在需要测试 Spring 特定层(Web、JPA)时,使用对应的测试切片(@WebMvcTest
, @DataJpaTest
)并用 @MockBean
替换外部依赖。遵循 AAA 原则、命名规范和最佳实践,编写快速、可靠、可维护的测试,是保障 Spring Boot 应用质量的基石。