SpringBoot单元测试实战:JUnit5与Mockito构建高质量代码保障体系
1. 项目概述:为什么单元测试是高质量代码的基石
在开发SpringBoot应用时,我们常常陷入一种“功能驱动”的循环:快速实现业务逻辑,通过Postman或浏览器手动点击几下,功能跑通就万事大吉。直到某天,一个看似简单的字段修改,却引发了线上订单支付的连环报错,排查到深夜才发现是某个Service层方法的一个边界条件没处理好。这种场景,相信不少后端开发都经历过。问题的根源,往往在于我们对代码的“信心”是建立在脆弱的手工验证之上,而非一套稳固、自动化的质量保障体系。单元测试,正是构建这套体系的第一道,也是最重要的一道防线。
我见过很多项目,初期为了赶进度完全忽略单元测试,随着代码量膨胀到几十万行,再想补测试简直是一场灾难,最终只能陷入“不敢改、不能动”的泥潭。相反,那些从项目初期就坚持编写高质量单元测试的团队,代码的可维护性和迭代速度会随着时间推移而显著提升。本次实战,我们就聚焦于SpringBoot环境下,如何运用JUnit5和Mockito这两个黄金组合,写出不仅“能用”,而且“可靠”、易于维护的单元测试,并最终通过测试覆盖率这个客观指标来量化我们的测试成果。无论你是刚开始接触单元测试的新手,还是想优化现有测试套件的老手,这套方法论都能给你带来直接的、可落地的价值。
2. 环境搭建与核心依赖配置
2.1 项目初始化与依赖引入
我们从一个全新的SpringBoot 3.x项目开始。使用Spring Initializr(或IDE的创建向导)生成项目时,除了选择必要的Web、JPA等依赖, 务必勾选“Spring Boot Test” 这个Starter。它会为我们引入 spring-boot-starter-test ,这是所有测试依赖的集合包。
查看生成的 pom.xml ,你会看到类似下面的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
这个Starter默认包含了JUnit Jupiter(JUnit5的核心)、Spring Test、AssertJ、Hamcrest、Mockito、JSONassert等一整套测试工具。对于大多数场景,这一个依赖就够了。但为了更清晰地管理和使用Mockito,我习惯显式地引入其核心依赖,这能避免因SpringBoot版本升级带来的潜在兼容性问题,也方便我们锁定特定版本。
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<!-- 如果需要Mockito的Inline mock maker(用于Mock final类/方法),可以额外添加 -->
<!-- <dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<scope>test</scope>
</dependency> -->
2.2 测试目录结构与基础注解
SpringBoot遵循Maven/Gradle的标准目录结构。所有的测试类都应该放在 src/test/java 下,并且其包路径最好与 src/main/java 中被测类的包路径保持一致。例如,你的 UserService 在 com.example.service 包下,那么它的测试类 UserServiceTest 就应该放在 src/test/java/com/example/service 下。这样做的好处是,IDE和构建工具能更容易地关联测试与源码。
在测试类的顶部,我们会使用 @SpringBootTest 注解。这个注解会启动一个完整的Spring应用上下文,用于集成测试。但对于 单元测试 ,我们的目标是测试单个类(如Service)的孤立功能,加载整个Spring容器既笨重又缓慢。因此,更推荐使用 @ExtendWith 注解组合。
JUnit5的最佳实践是使用 @SpringBootTest 的 webEnvironment 属性或更轻量的配置:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.test.context.SpringBootTest;
// 方式一:轻量级单元测试,不启动Web容器,仅加载你指定的配置
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ExtendWith(MockitoExtension.class) // 启用Mockito支持
public class UserServiceUnitTest {
// ...
}
// 方式二:更极致的单元测试,连Spring容器都不启动,纯POJO测试(需结合@MockBean等)
// 通常用于测试不依赖Spring容器的工具类或纯逻辑组件
对于大多数Service层的单元测试,我强烈推荐方式一。它平衡了隔离性和便利性,既能使用 @MockBean 来模拟Spring管理的Bean,又避免了启动Tomcat等Web服务器的开销,测试执行速度非常快。
3. JUnit5核心特性与实战应用
JUnit5相对于JUnit4是一次彻底的重构,模块化更清晰,功能也更强大。我们重点掌握几个最常用的特性。
3.1 生命周期注解:@BeforeEach, @AfterEach
在JUnit4中,我们用 @Before 和 @After 。在JUnit5中,它们被更语义化的 @BeforeEach 和 @AfterEach 取代。这两个注解标注的方法会在 每一个 @Test 方法执行 之前 和 之后 运行。
public class OrderServiceTest {
private OrderService orderService;
private SomeExpensiveResource resource;
@BeforeEach
void setUp() {
// 在每个测试方法前执行,用于初始化测试环境
resource = new SomeExpensiveResource();
resource.init();
orderService = new OrderService(resource);
}
@AfterEach
void tearDown() {
// 在每个测试方法后执行,用于清理资源
if (resource != null) {
resource.close();
}
}
@Test
void testCreateOrder() {
// 这里的orderService已经是setUp中初始化好的
}
}
注意 :
@BeforeEach方法应该是void类型,并且不应该抛出异常(除非你希望整个测试类失败)。确保setUp和tearDown是幂等的,即多次执行效果相同,因为理论上每个测试都是独立的。
3.2 断言库的升级:从JUnit Assert到AssertJ
JUnit5自带的 Assertions 类已经比JUnit4好用,但社区更推崇AssertJ。它提供了一套 流式API ,断言语句读起来就像自然语言,并且错误信息极其友好。
对比一下:
// JUnit5 原生断言
import static org.junit.jupiter.api.Assertions.*;
@Test
void testWithJUnitAssert() {
List<String> result = someService.getList();
assertNotNull(result);
assertEquals(3, result.size());
assertTrue(result.contains("expectedItem"));
}
// AssertJ 流式断言
import static org.assertj.core.api.Assertions.*;
@Test
void testWithAssertJ() {
List<String> result = someService.getList();
assertThat(result)
.isNotNull()
.hasSize(3)
.contains("expectedItem")
.doesNotContainNull()
.allSatisfy(item -> assertThat(item.length()).isGreaterThan(0));
}
AssertJ的错误信息在失败时直接告诉你具体哪里不对,比如“expected size 3 but was 2”,并且链式调用让测试代码更清晰。 spring-boot-starter-test 默认包含了AssertJ,直接使用即可。
3.3 参数化测试:@ParameterizedTest
这是JUnit5的一大亮点,允许你用不同的输入参数多次运行同一个测试逻辑。非常适合测试边界条件、有效/无效输入等场景。
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t\n"}) // 参数来源:直接提供值
void testIsBlank_ShouldReturnTrueForAllBlankInputs(String input) {
assertThat(StringUtils.isBlank(input)).isTrue();
}
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, -5, 5"
}) // 参数来源:CSV格式,每行一组参数
void testAdd(int a, int b, int expectedSum) {
Calculator calc = new Calculator();
assertThat(calc.add(a, b)).isEqualTo(expectedSum);
}
@ParameterizedTest
@MethodSource("provideTestData") // 参数来源:指定一个返回Stream的方法
void testWithMethodSource(int input, boolean expected) {
assertThat(SomeUtil.isValid(input)).isEqualTo(expected);
}
static Stream<Arguments> provideTestData() {
return Stream.of(
Arguments.of(0, false),
Arguments.of(5, true),
Arguments.of(100, true),
Arguments.of(101, false)
);
}
参数化测试能极大减少重复的测试代码,让测试用例的意图更明确。 实操心得 :对于业务规则复杂的计算、验证逻辑,优先考虑使用参数化测试来覆盖各种临界情况。
4. Mockito深度使用:模拟、打桩与验证
单元测试的核心是“隔离”。我们的目标是测试 A 类,但如果 A 依赖 B 、 C 等外部组件(如数据库访问层 Repository 、第三方服务客户端 Client ),我们就需要把这些依赖“模拟”出来。Mockito就是干这个的。
4.1 创建Mock对象与注入依赖
有两种主要方式创建Mock对象:
-
使用注解(推荐) :结合
@ExtendWith(MockitoExtension.class)使用。@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private OrderRepository orderRepository; // 被Mock的依赖 @Mock private PaymentClient paymentClient; @InjectMocks private OrderService orderService; // 被测试类,Mock会自动注入 @Test void testPlaceOrder() { // ... 测试逻辑 } }@InjectMocks会让Mockito尝试通过构造函数、setter或字段注入的方式,将@Mock标注的模拟对象注入到orderService中。这是最简洁的方式。 -
手动创建 :
class OrderServiceTest { private OrderRepository orderRepository; private OrderService orderService; @BeforeEach void setUp() { orderRepository = Mockito.mock(OrderRepository.class); orderService = new OrderService(orderRepository); // 通过构造函数注入 } }
4.2 行为打桩:when(...).thenReturn(...)
打桩就是定义当Mock对象的方法被调用时,应该返回什么值或抛出什么异常。
@Test
void testFindOrderById_Success() {
// 1. 准备测试数据
Long orderId = 123L;
Order mockOrder = new Order();
mockOrder.setId(orderId);
mockOrder.setStatus(OrderStatus.CREATED);
// 2. 打桩:当orderRepository.findById(orderId)被调用时,返回一个包含mockOrder的Optional
when(orderRepository.findById(orderId)).thenReturn(Optional.of(mockOrder));
// 3. 执行被测方法
OrderDTO result = orderService.getOrderDetail(orderId);
// 4. 断言结果
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(orderId);
assertThat(result.getStatus()).isEqualTo("CREATED");
}
高级打桩技巧:
thenThrow(): 模拟方法抛出异常。when(paymentClient.process(any())).thenThrow(new RuntimeException("网络超时"));thenAnswer(): 根据调用参数动态返回结果。when(idGenerator.generate()).thenAnswer(invocation -> "ID-" + System.currentTimeMillis());- 连续打桩 :同一个方法调用,第一次和第二次返回不同结果。
when(iterator.next()) .thenReturn("first") .thenReturn("second") .thenThrow(new NoSuchElementException());
4.3 参数匹配器:any(), eq()
在打桩或验证时,我们不一定关心具体的参数值,可以使用参数匹配器。
// 当save方法被调用,且传入的参数是任意Order类型时,都返回这个预定义的Order
when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);
// 更精确的匹配:当findByStatusAndUserId被调用,且第一个参数是“PAID”,第二个参数是具体的userId时
when(orderRepository.findByStatusAndUserId(eq(OrderStatus.PAID), eq(userId)))
.thenReturn(paidOrders);
踩坑提醒 :一旦在方法调用中使用了一个参数匹配器(如 any() ),那么 该方法的所有参数都必须使用匹配器 ,不能混用具体值和匹配器。 eq() 就是一个将具体值包装成匹配器的工具。
4.4 验证交互:verify()
单元测试不仅要验证结果(状态验证),有时还需要验证行为(交互验证),即某个依赖方法是否被调用、调用了几次、以什么参数调用。
@Test
void testPlaceOrder_ShouldSaveAndCallPayment() {
// ... 准备数据、打桩
orderService.placeOrder(orderRequest);
// 验证 orderRepository.save 被调用了一次,且参数是 order 对象
verify(orderRepository, times(1)).save(orderArgumentCaptor.capture());
// 验证 paymentClient.process 被调用了一次,且参数是特定的 paymentRequest
verify(paymentClient, times(1)).process(eq(expectedPaymentRequest));
// 绝对禁止调用某个方法
verify(emailService, never()).send(any());
}
verify 是行为测试的利器。 常见误区 :不要过度使用 verify 。单元测试应更关注被测对象的输出状态(返回值、状态变更),而不是其内部每一个交互细节,否则测试会变得极其脆弱,重构代码时测试也需要大量修改。
5. SpringBoot测试特有技巧:@MockBean与@TestConfiguration
5.1 @MockBean vs @Mock
在纯单元测试(不启动Spring容器)中,我们用 @Mock 。但在集成测试或需要测试Spring Bean(如Controller)时,我们需要 @MockBean 。 @MockBean 是SpringBoot提供的注解,它会将Mock对象添加到Spring的 ApplicationContext 中,替换掉上下文中同类型的真实Bean。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // 启动真实Web环境
class OrderControllerIntegrationTest {
@Autowired
private TestRestTemplate restTemplate; // SpringBoot提供的测试客户端
@MockBean
private OrderService orderService; // 替换掉真实的OrderService Bean
@Test
void testGetOrderApi() {
OrderDTO mockDto = new OrderDTO();
when(orderService.getOrderDetail(anyLong())).thenReturn(mockDto);
ResponseEntity<OrderDTO> response = restTemplate.getForEntity("/api/orders/123", OrderDTO.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo(mockDto);
verify(orderService).getOrderDetail(123L);
}
}
关键区别 : @Mock 是Mockito管理的, @MockBean 是Spring管理的。在 @SpringBootTest 中,如果你想模拟一个Spring Bean,必须用 @MockBean 。
5.2 使用@TestConfiguration定制测试上下文
有时候,我们只想在测试环境中替换某个特定的Bean,或者添加一些测试专用的配置。这时可以用 @TestConfiguration 。
@SpringBootTest
class ServiceWithExternalClientTest {
@Autowired
private MyService myService;
@Test
void testService() {
// myService 会使用下面内部类中定义的 testClient
myService.doSomething();
}
@TestConfiguration // 这个配置只对当前测试类生效
static class TestConfig {
@Bean
@Primary // 优先使用这个Bean,覆盖主配置中的同名Bean
public ExternalClient testClient() {
// 返回一个模拟的、轻量级的客户端,而不是连接真实外部服务的客户端
return new FakeExternalClient();
}
}
}
@TestConfiguration 是一个非常灵活的工具,可以让你在不污染主应用代码的情况下,为测试创建特定的Bean或配置,比如使用内存数据库代替MySQL,使用假的短信发送器等。
6. 测试覆盖率收集与报告解读
写了测试,怎么知道写得够不够?测试覆盖率是一个重要的量化指标。它主要衡量你的测试代码执行了被测源码的哪些部分,通常包括 行覆盖率 、 分支覆盖率 、 方法覆盖率 等。
6.1 使用JaCoCo收集覆盖率
JaCoCo是Java生态最流行的覆盖率工具。在Maven项目中集成非常简单,在 pom.xml 的 <build><plugins> 中添加:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version> <!-- 使用最新版本 -->
<executions>
<execution>
<goals>
<goal>prepare-agent</goal> <!-- 在测试执行时附加JaCoCo代理 -->
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase> <!-- 在verify阶段生成报告 -->
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
配置好后,运行 mvn clean verify (或 mvn test ,但 verify 会运行所有生命周期到 verify 的阶段,包括集成测试)。执行完成后,会在 target/site/jacoco/ 目录下生成HTML格式的覆盖率报告。
6.2 解读覆盖率报告
打开 index.html ,你会看到类似这样的数据:
- 指令覆盖率 (Instruction Coverage) :最基础的覆盖率,衡量字节码指令被执行的比例。
- 行覆盖率 (Line Coverage) :最直观的指标,衡量多少行源代码被执行过。
- 分支覆盖率 (Branch Coverage) : 这个指标更重要 。它衡量代码中每个if/else、switch、三元运算符等分支是否都被测试到。例如一个
if (condition)语句,你需要有测试用例分别让condition为true和false,才能达到100%的分支覆盖。 - 方法覆盖率 (Method Coverage) :衡量有多少个方法被调用过。
如何设定合理的覆盖率目标?
- 不要盲目追求100% :100%行覆盖可能都很难,100%分支覆盖成本极高。对工具生成的(如Lombok的
@Data)、简单的Getter/Setter、或某些异常捕获后仅打印日志的代码块,追求覆盖可能得不偿失。 - 核心业务逻辑是关键 :你的
Service层、核心算法、关键业务规则处理代码,应该追求高覆盖率(例如85%以上的行覆盖,80%以上的分支覆盖)。 - 分层设定目标 :DAO层(大量简单CRUD)和Controller层(参数校验、格式转换)的覆盖率要求可以低于核心业务逻辑层。
- 结合CI/CD :在持续集成流水线中设置覆盖率门槛(例如核心模块行覆盖率<80%则构建失败),让覆盖率检查成为自动化流程的一部分。
6.3 覆盖率的局限性
高覆盖率不等于高质量测试! 这是一个必须牢记的准则。覆盖率只能告诉你代码“被执行过”,但不能告诉你“被正确地验证过”。
// 一个糟糕的测试例子
@Test
void testDivide() {
Calculator calc = new Calculator();
int result = calc.divide(10, 2); // 这行代码会被覆盖到
// 忘记写断言了!即使除数为0的bug存在,测试也会通过。
}
这个测试的覆盖率是100%(因为执行了 divide 方法),但它完全没有验证结果是否正确,也没有测试除数为0的异常情况。因此, 有意义的断言(Assertions)比单纯的覆盖率数字更重要 。覆盖率是一个有用的辅助工具和趋势指标,可以帮助你发现未被测试的“死角”,但不能替代严谨的测试用例设计。
7. 编写可维护测试代码的实践与模式
写出能通过编译和运行的测试只是第一步,写出易于理解、维护和扩展的测试才是终极目标。
7.1 测试命名规范
好的测试名应该清晰地表达“在什么条件下,执行什么操作,期望什么结果”。推荐使用 [方法名]_[测试场景]_[预期结果] 的格式。
// 反例:名字毫无信息量
@Test
void test1() { ... }
// 正例:清晰表达意图
@Test
void placeOrder_WithValidRequest_ShouldReturnOrderId() { ... }
@Test
void placeOrder_WithOutOfStockItem_ShouldThrowInventoryException() { ... }
@Test
void getUserById_WhenUserNotFound_ShouldReturnNull() { ... }
7.2 Given-When-Then模式
这是组织测试代码的经典模式,让测试结构一目了然。
@Test
void applyDiscount_WhenCustomerIsVIPAndOrderAmountOver1000_ShouldApply20PercentDiscount() {
// Given: 准备测试数据和前置条件
Customer vipCustomer = new Customer();
vipCustomer.setLevel(CustomerLevel.VIP);
Order order = new Order();
order.setCustomer(vipCustomer);
order.setTotalAmount(new BigDecimal("1200.00"));
DiscountService discountService = new DiscountService();
// When: 执行被测方法
BigDecimal finalAmount = discountService.applyDiscount(order);
// Then: 验证结果和行为
assertThat(finalAmount).isEqualByComparingTo("960.00"); // 1200 * 0.8 = 960
}
7.3 测试数据构建的优化
在测试中构造复杂的对象非常繁琐。可以使用 建造者模式(Builder Pattern) 或 对象工厂方法 。
// 使用建造者模式(Lombok @Builder 可以自动生成)
Order.OrderBuilder orderBuilder = Order.builder()
.id(1L)
.status(OrderStatus.PENDING)
.customer(Customer.builder().name("张三").vip(true).build());
// 在测试类中定义工厂方法
private Order createTestOrder(Long id, OrderStatus status) {
Order order = new Order();
order.setId(id);
order.setStatus(status);
order.setCreateTime(LocalDateTime.now());
// ... 设置其他通用字段
return order;
}
// 在测试中使用
Order pendingOrder = createTestOrder(1L, OrderStatus.PENDING);
对于更复杂的情况,可以考虑使用 Data Faker库 (如 java-faker )来生成逼真的测试数据。
7.4 保持测试的独立性与隔离性
每个 @Test 方法都应该是独立的,不依赖于其他测试方法的执行顺序或状态。JUnit5默认是随机顺序执行测试的。确保:
- 不使用共享的、可变的类级别变量(除非是
static final常量)。 - 如果使用了
@BeforeEach来初始化,要确保它能将环境重置到干净状态。 - 模拟对象(Mock)的行为最好在每个测试方法内部定义,避免在
@BeforeEach中定义全局打桩,除非该行为在所有测试中都通用。
8. 常见问题排查与调试技巧
8.1 常见异常与解决
-
NullPointerExceptionin@InjectMocks:通常是因为被测试类有多个构造函数,或者依赖项无法通过反射注入。解决方案:使用@BeforeEach方法手动构造被测对象,或者为被测试类提供一个包可见的构造函数以便于测试。 -
UnfinishedStubbingException:通常是因为错误的打桩语法,比如when(mock.method())时,method()内部又调用了另一个需要打桩的方法。仔细检查when()括号内的代码。 -
Argument(s) are different!inverify():验证方法调用时参数不匹配。使用ArgumentCaptor捕获实际参数进行调试,或者检查是否应该使用参数匹配器any()而不是具体值eq()。 - 测试通过但实际代码有问题 :最常见的原因是打桩过于宽泛。例如,用
any()匹配了所有参数,但实际业务逻辑中不同参数会导致不同分支。需要更精确地打桩,或者补充更多测试用例。
8.2 使用ArgumentCaptor进行调试
当验证方法调用参数复杂,或者你想深入检查传入的参数对象时, ArgumentCaptor 非常有用。
@Test
void testComplexMethodCall() {
// 假设我们要验证 save 方法被调用,并且传入的 Order 对象状态是 PAID
@Captor
ArgumentCaptor<Order> orderCaptor;
// 或者手动创建:ArgumentCaptor<Order> orderCaptor = ArgumentCaptor.forClass(Order.class);
// 执行被测方法...
verify(orderRepository).save(orderCaptor.capture()); // 捕获参数
Order capturedOrder = orderCaptor.getValue(); // 获取捕获的参数
// 现在可以对这个对象进行详细的断言
assertThat(capturedOrder.getStatus()).isEqualTo(OrderStatus.PAID);
assertThat(capturedOrder.getPaidTime()).isNotNull();
}
8.3 测试日志输出
在测试类中临时添加日志输出,可以帮助理解测试执行流程和调试问题。确保你的 src/test/resources 目录下有 logback-test.xml 或 application-test.properties 来配置测试环境的日志级别(例如设置为 DEBUG )。
8.4 测试不稳定的问题(Flaky Tests)
有些测试有时通过,有时失败,这通常是因为:
- 依赖外部服务或状态 :测试调用了真实的数据库、网络API。 必须用Mock隔离 。
- 并发问题 :测试中使用了共享的、非线程安全的资源。确保每个测试独立。
- 时间依赖 :测试中使用了
new Date()、System.currentTimeMillis()。可以使用Clock类进行抽象,在测试中注入一个固定的时钟。 - 执行顺序依赖 :测试依赖于全局状态(如静态变量)。重构代码,避免使用可变的静态状态。
处理不稳定测试的最高优先级是 修复它 ,而不是重试或忽略。一个不稳定的测试会严重损害整个测试套件的可信度。
9. 进阶话题:测试分层与集成测试
单元测试(Unit Test)关注单个类或方法。但在实际项目中,我们还需要其他层次的测试。
9.1 测试金字塔
健康的自动化测试结构应该像一个金字塔:
- 底层(最多) : 单元测试 。快速、稳定、隔离。是测试的基石。
- 中层 : 集成测试 。验证多个组件(如Service和Repository)协同工作是否正常。速度中等。
- 高层(最少) : 端到端测试 。验证整个应用从UI到数据库的完整流程。速度慢,脆弱,但信心足。
SpringBoot的 @SpringBootTest 可以用来写集成测试。你可以使用 @DataJpaTest 来测试JPA Repository层(它会配置一个内存数据库),用 @WebMvcTest 来切片测试Controller层(只加载Web层相关的Bean)。
9.2 @DataJpaTest 实践
这个注解专门用于测试JPA层。它会配置一个内存数据库(如H2),并自动注入 TestEntityManager 。
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private TestEntityManager entityManager; // 用于持久化测试数据
@Autowired
private OrderRepository orderRepository;
@Test
void findByStatus_ShouldReturnOrders() {
// Given: 使用 TestEntityManager 将数据持久化到内存数据库
Order order = new Order();
order.setStatus(OrderStatus.PAID);
entityManager.persistAndFlush(order);
// When: 调用真实的Repository方法
List<Order> found = orderRepository.findByStatus(OrderStatus.PAID);
// Then: 验证数据库查询结果
assertThat(found).hasSize(1).extracting(Order::getStatus).containsOnly(OrderStatus.PAID);
}
}
这种测试比纯Mock的单元测试更“真实”,因为它实际执行了SQL,能发现一些映射和查询语法问题。
9.3 @WebMvcTest 实践
这个注解用于切片测试Controller,不会加载完整的应用上下文,只加载与Web层相关的配置(如 @Controller , @RestController , @JsonComponent 等),Service层需要用 @MockBean 来模拟。
@WebMvcTest(OrderController.class) // 只加载OrderController相关的Web配置
class OrderControllerTest {
@Autowired
private MockMvc mockMvc; // 模拟HTTP请求的利器
@MockBean
private OrderService orderService;
@Test
void getOrder_ShouldReturnOrder() throws Exception {
OrderDTO mockDto = new OrderDTO();
mockDto.setId(1L);
when(orderService.getOrderDetail(1L)).thenReturn(mockDto);
mockMvc.perform(get("/api/orders/1") // 发起GET请求
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) // 断言HTTP状态码
.andExpect(jsonPath("$.id").value(1)); // 断言JSON响应体
}
}
MockMvc 提供了强大且流畅的API来测试Controller的HTTP接口,包括请求头、参数、内容类型、响应状态和内容验证。
10. 将测试融入开发流程
单元测试不是开发完成后才补的作业,而应该是开发流程中不可分割的一部分。
10.1 测试驱动开发
测试驱动开发是一种先写测试,再写实现代码的开发方式。其循环是“红-绿-重构”:
- 红 :写一个失败的测试(描述你期望的功能)。
- 绿 :写最简单的代码让这个测试通过。
- 重构 :在测试保护下,优化代码结构,消除重复。
TDD能迫使你从调用者角度思考设计,往往能得到接口更清晰、耦合度更低的代码。虽然一开始不习惯,但对于核心、复杂的业务逻辑,尝试TDD会有意想不到的收获。
10.2 在CI/CD中运行测试
在持续集成/持续部署流水线中,自动化运行测试是基本要求。通常的步骤是:
- 代码推送到版本库(如Git)。
- CI服务器(如Jenkins, GitLab CI, GitHub Actions)触发构建。
- 运行
mvn clean verify(这会运行所有单元测试和集成测试)。 - 如果测试失败,构建标记为失败,阻止向更高级环境部署。
- 同时,可以配置CI任务生成并发布JaCoCo覆盖率报告,甚至设置覆盖率质量门禁。
10.3 测试代码的版本控制
测试代码和产品代码同等重要,必须一起提交到版本控制系统。一个常见的坏习惯是本地测试通过了就提交,却忘了把新增的测试文件 git add 进去。确保你的 .gitignore 文件没有忽略 src/test/ 目录下的内容。
最后,记住单元测试的终极目标不是追求工具和框架的炫技,而是为了 提升代码质量、减少缺陷、并赋予开发者安全重构的信心 。从今天开始,为你新增的每一个业务方法配上至少一个测试用例,逐步构建起项目的安全网,你会发现,长期来看,这非但不是负担,反而是提升开发效率和幸福感的强大引擎。
更多推荐



所有评论(0)