Java 单元测试怎么写:JUnit 5、Mockito 与 Spring Boot 方法级实战
文章目录
- [Java 单元测试怎么写:JUnit 5、Mockito 与 Spring Boot 方法级实战](#Java 单元测试怎么写:JUnit 5、Mockito 与 Spring Boot 方法级实战)
-
- [1. 先回答核心问题:测试的对象是方法承诺,不是代码行](#1. 先回答核心问题:测试的对象是方法承诺,不是代码行)
- [2. 从业务方法到测试用例:五步闭环](#2. 从业务方法到测试用例:五步闭环)
-
- [2.1 第一步:写方法契约](#2.1 第一步:写方法契约)
- [2.2 第二步:列场景矩阵](#2.2 第二步:列场景矩阵)
- [2.3 第三步:只隔离真正的边界](#2.3 第三步:只隔离真正的边界)
- [2.4 第四步:一个测试只执行一个业务动作](#2.4 第四步:一个测试只执行一个业务动作)
- [2.5 第五步:断言结果,再断言必要交互](#2.5 第五步:断言结果,再断言必要交互)
- [3. JUnit 5 方法和注解:每一个放在哪里、解决什么问题](#3. JUnit 5 方法和注解:每一个放在哪里、解决什么问题)
-
- [3.1 生命周期方法](#3.1 生命周期方法)
- [3.2 测试、分组与跳过](#3.2 测试、分组与跳过)
- [3.3 参数化测试](#3.3 参数化测试)
- [3.4 JUnit 断言方法](#3.4 JUnit 断言方法)
- [4. AssertJ:让断言直接表达业务结果](#4. AssertJ:让断言直接表达业务结果)
- [5. Mockito:按方法解释每个 API 的职责](#5. Mockito:按方法解释每个 API 的职责)
-
- [5.1 初始化和替身](#5.1 初始化和替身)
- [5.2 配置依赖行为:`when`、`thenReturn`、`thenThrow`](#5.2 配置依赖行为:
when、thenReturn、thenThrow) - [5.3 验证必要交互:`verify`、`never`、`times`](#5.3 验证必要交互:
verify、never、times) - [5.4 参数和顺序:`eq`、`ArgumentCaptor`、`InOrder`](#5.4 参数和顺序:
eq、ArgumentCaptor、InOrder)
- [6. 完整方法级示例:从生产方法到三个测试](#6. 完整方法级示例:从生产方法到三个测试)
-
- [6.1 被测生产代码](#6.1 被测生产代码)
- [6.2 测试类与成功路径](#6.2 测试类与成功路径)
- [6.3 失败路径:订单不存在和重复支付](#6.3 失败路径:订单不存在和重复支付)
- [6.4 依赖抛错时怎么写](#6.4 依赖抛错时怎么写)
- [7. 不同 Java 类应选择什么测试方法](#7. 不同 Java 类应选择什么测试方法)
-
- [7.1 工具类、解析器:不用 Spring,直接测输入输出](#7.1 工具类、解析器:不用 Spring,直接测输入输出)
- [7.2 Service/Engine:结果优先,交互补充](#7.2 Service/Engine:结果优先,交互补充)
- [7.3 Controller:直接调用、MockMvc、`@WebMvcTest` 分层](#7.3 Controller:直接调用、MockMvc、
@WebMvcTest分层) - [7.4 DAO/Mapper:文本契约和真实数据库分开](#7.4 DAO/Mapper:文本契约和真实数据库分开)
- [7.5 异步与重试:验证状态机,不要靠大超时](#7.5 异步与重试:验证状态机,不要靠大超时)
- [8. 项目源码对应关系:引用位置说明了什么](#8. 项目源码对应关系:引用位置说明了什么)
- [9. 常见错误:为什么测试会绿却不可信](#9. 常见错误:为什么测试会绿却不可信)
- [10. 执行命令、验证边界与排错](#10. 执行命令、验证边界与排错)
- [11. 最终判断:一份测试是否值得信任](#11. 最终判断:一份测试是否值得信任)
- [附录:测试 API 速查表](#附录:测试 API 速查表)
内容摘要:本文解决"Java 中到底怎样为一个方法写出可信测试"这一问题。以 JUnit 5、AssertJ、Mockito 和 Spring Boot 为主线,从方法契约、场景矩阵、测试夹具到断言与依赖隔离,给出可照抄的
OrderService.pay完整示例,并解释每个注解、API、断言和验证方法证明了什么。文中同时说明纯单元测试、Web 层测试、Mapper 契约测试和集成测试的边界,避免把"调用过"误当成"行为正确"。
1. 先回答核心问题:测试的对象是方法承诺,不是代码行
写测试前先把方法说成一句可验证的话:在什么前置条件下,输入什么,应该得到什么结果,并且哪些副作用必须发生或绝不能发生。
例如:
OrderService.pay(订单号)只有在订单存在且状态为PENDING时才允许扣款;扣款成功后保存PAID状态并返回已支付订单;订单不存在、状态不允许或扣款失败时,不得保存错误状态。
这句话比"覆盖 pay 方法所有分支"更有用,因为它直接决定测试场景和断言。一个测试只证明它实际断言的行为,不能因为执行过某行代码就推导出生产可用。
本文使用 Java 17、Maven、JUnit Jupiter 5、Mockito 和 AssertJ 说明写法。示例代码是独立示意,未声称属于当前项目源码;项目中的真实引用位置放在"项目源码对应关系"一节。
| 测试类型 | 被测边界 | 常用入口 | 能证明什么 | 不能证明什么 |
|---|---|---|---|---|
| 纯单元测试 | 一个类的规则 | 直接 new 被测类 |
输入、输出、异常和状态规则 | Spring 装配、真实 SQL、网络 |
| Mockito 单元测试 | 一个类及其外部协作者 | @Mock、构造器注入 |
规则与必要交互 | Mock 与真实实现的一致性 |
| Web 层测试 | Controller、路由、安全过滤器 | @WebMvcTest、MockMvc |
HTTP 状态、JSON、权限 | 真实数据库和外部服务 |
| 集成测试 | 多个真实组件 | @SpringBootTest、Testcontainers |
装配、SQL、事务、连接契约 | 生产规模、真实流量和部署拓扑 |
| 手工/端到端验证 | 指定环境完整链路 | *ManualIT、真实服务 |
真实配置下的结果 | 快速、稳定、可重复的回归 |
2. 从业务方法到测试用例:五步闭环
2.1 第一步:写方法契约
把返回值、副作用和失败语义列出来:
| 契约项 | pay 示例 |
|---|---|
| 输入 | orderId,不能为空且必须指向订单 |
| 正常结果 | 返回状态为 PAID 的订单 |
| 必要副作用 | 调用支付网关;保存 PAID 订单 |
| 禁止副作用 | 找不到订单或状态不允许时不得扣款、不得保存 |
| 异常 | 订单不存在抛 IllegalArgumentException;状态不允许抛 IllegalStateException |
| 不变量 | 只有 PENDING 才能进入扣款流程 |
2.2 第二步:列场景矩阵
不要先写代码再猜覆盖率。先把等价类和边界列出:
| 场景 | 前置数据 | 预期结果 | 交互断言 |
|---|---|---|---|
| 成功支付 | 订单存在且 PENDING |
返回 PAID |
扣款一次、保存一次 |
| 订单不存在 | Repository 返回空 | 抛订单不存在异常 | 不扣款、不保存 |
| 重复支付 | 状态为 PAID |
抛状态异常 | 不再次扣款 |
| 网关失败 | 订单为 PENDING,网关抛异常 |
异常向上抛出 | 不保存 PAID |
| 边界输入 | null、非法 ID |
明确拒绝 | 不访问不必要依赖 |
2.3 第三步:只隔离真正的边界
Repository、支付网关、HTTP 客户端、时钟、消息队列属于不稳定或有副作用的边界,可以替换;Order 这样的值对象应该使用真实实例,否则测试只是在配置 Mock 的 getter 返回值。
2.4 第四步:一个测试只执行一个业务动作
每个测试通常只有一次 Act 调用,例如一次 service.pay(1L)。多个 when 是准备条件,不是多个被测动作。若一个方法包含注册、扣费、发货三个独立规则,应拆成多个测试或提取协作者。
2.5 第五步:断言结果,再断言必要交互
结果断言回答"用户得到什么";交互断言回答"跨边界动作是否按契约发生"。只写 verify(repository).save(any()) 不能证明保存的是正确状态;只写 isNotNull() 也不能证明字段正确。
3. JUnit 5 方法和注解:每一个放在哪里、解决什么问题
3.1 生命周期方法
java
@BeforeEach
void setUp() {
// 每个测试前重新创建 fixture,避免测试之间共享可变状态
}
@AfterEach
void tearDown() {
// 关闭 MockWebServer、恢复系统属性等资源
}
@BeforeAll
static void beforeAll() {
// 整个测试类只执行一次;默认必须是 static
}
@AfterAll
static void afterAll() {
// 整个测试类结束后清理一次
}
@BeforeEach 适合构造每个场景自己的对象;不要在这里创建会被多个测试修改的全局订单。@BeforeAll 适合昂贵且只读的资源,但全局状态会引入并行测试风险。
3.2 测试、分组与跳过
@Test:标记一个独立测试方法。@DisplayName("...中文业务描述..."):让报告直接显示业务规则。@Nested:按"订单不存在""状态不允许"等前置条件分组,不能替代独立 fixture。@Tag("unit")、@Tag("integration"):分类后还要在 Maven/CI 中配置筛选才会生效。@Disabled("原因"):临时跳过必须写原因,不能用来隐藏长期失败。
3.3 参数化测试
java
@ParameterizedTest(name = "输入 {0} 应被拒绝")
@NullSource
@EmptySource
@ValueSource(strings = {" ", "\t"})
void blankSkuIsRejected(String value) {
assertThatThrownBy(() -> parser.parse(value))
.isInstanceOf(IllegalArgumentException.class);
}
@ValueSource 适合简单字面量;@CsvSource 适合多个简单参数;@MethodSource 适合对象、集合和复杂场景;@NullSource 与 @EmptySource 不等价,空白字符串仍需单独加入。参数化测试的每组输入都必须能独立解释失败原因。
3.4 JUnit 断言方法
| 方法 | 用法 | 它真正证明什么 |
|---|---|---|
assertEquals(expected, actual) |
值相等 | 结果值符合契约 |
assertTrue / assertFalse |
布尔规则 | 条件成立或不成立 |
assertNull / assertNotNull |
缺失/存在 | 只能证明存在性,不能证明内容正确 |
assertThrows(Type.class, executable) |
异常类型 | 非法输入走到指定失败出口 |
assertDoesNotThrow |
合法输入不抛异常 | 仍需继续断言返回内容 |
assertAll |
一次聚合多个相关字段 | 同一个结果的多个属性同时满足 |
assertTimeout |
纯内存、可控代码的时间上限 | 本地逻辑没有超出预算,不能替代 HTTP 超时测试 |
实际项目中 AssertJ 的 assertThat 更常见;JUnit 原生断言与 AssertJ 可以混用,但同一测试最好保持风格一致。
4. AssertJ:让断言直接表达业务结果
java
assertThat(result)
.isNotNull()
.extracting(Order::status)
.isEqualTo("PAID");
assertThat(orders)
.hasSize(2)
.extracting(Order::id)
.containsExactly(1L, 2L);
assertThatThrownBy(() -> service.pay(9L))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("订单不存在");
containsExactly 同时检查元素和顺序;如果顺序不是业务契约,应使用 containsExactlyInAnyOrder。hasSize(2) 不能替代元素内容检查;isNotNull() 不能替代状态、金额和权限断言。异常 Lambda 内只放被测调用,否则 fixture 自己抛错也可能被误判为测试通过。
5. Mockito:按方法解释每个 API 的职责
5.1 初始化和替身
java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository repository;
@Mock
private PaymentGateway paymentGateway;
@InjectMocks
private OrderService service;
}
@ExtendWith(MockitoExtension.class):让 JUnit 5 初始化 Mockito 注解。@Mock:创建依赖替身;不要 Mock 被测对象本身。@InjectMocks:按构造器、Setter 或字段把替身注入被测对象。构造器注入最容易发现缺失依赖。mock(Type.class):需要在单个测试临时创建替身时使用。
5.2 配置依赖行为:when、thenReturn、thenThrow
java
when(repository.findById(1L)).thenReturn(Optional.of(pending));
when(repository.findById(9L)).thenReturn(Optional.empty());
when(paymentGateway.charge(7L, amount))
.thenThrow(new PaymentException("网关拒绝"));
这些语句只建立 Arrange 条件,不证明调用发生。thenReturn 用于非异常返回;thenThrow 用于非 void 方法抛异常。void 方法要用 doThrow(...).when(mock).method(...)。
5.3 验证必要交互:verify、never、times
java
verify(paymentGateway).charge(7L, amount); // 默认恰好一次
verify(repository, times(1)).save(any(Order.class));
verify(paymentGateway, never()).charge(anyLong(), any());
verify 应只验证业务不可替代的边界动作:扣费、写状态、发布事件、禁止路径不产生副作用。times(1) 是默认值,不必到处显式写。never() 用于证明失败或无权限路径没有扣费、写库等危险副作用。
5.4 参数和顺序:eq、ArgumentCaptor、InOrder
java
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
verify(repository).save(captor.capture());
assertThat(captor.getValue().status()).isEqualTo("PAID");
InOrder order = inOrder(paymentGateway, repository);
order.verify(paymentGateway).charge(7L, amount);
order.verify(repository).save(any(Order.class));
一个调用中只要使用了 matcher(如 any()),其他参数也应使用 matcher(如 eq(7L)),否则 Mockito 会报参数匹配错误。ArgumentCaptor 适合检查跨边界写出的业务对象;不要捕获每个内部临时值。只有先扣款再保存这一顺序构成一致性契约时才使用 InOrder。
timeout() 只适合确实异步的提交点,例如 verify(worker, timeout(1000)).run();它会引入等待,不能代替可控的 Executor、Latch 或测试时钟。reset() 和 clearInvocations() 通常说明一个测试塞入了太多阶段,优先拆测试。
6. 完整方法级示例:从生产方法到三个测试
以下代码是示意代码,用于展示写法;未在当前文档目录编译。生产代码使用构造器注入,测试因此只需替换 Repository 和支付网关。
6.1 被测生产代码
java
package example.order;
import java.math.BigDecimal;
public final class OrderService {
private final OrderRepository repository;
private final PaymentGateway paymentGateway;
public OrderService(OrderRepository repository, PaymentGateway paymentGateway) {
this.repository = repository;
this.paymentGateway = paymentGateway;
}
public Order pay(Long orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("订单不存在"));
if (!"PENDING".equals(order.status())) {
throw new IllegalStateException("订单不处于待支付状态");
}
paymentGateway.charge(order.userId(), order.amount());
return repository.save(order.paid());
}
}
| 方法位置 | 输入/输出 | 业务规则 | 失败语义 |
|---|---|---|---|
findById |
orderId → Order |
订单必须存在 | 空结果转为"订单不存在" |
| 状态判断 | Order.status() |
只有 PENDING 可继续 |
其他状态拒绝 |
charge |
用户、金额 | 扣款是外部副作用 | 网关异常向上抛出 |
save |
order.paid() → Order |
扣款后才保存 PAID |
保存失败不应伪造成功 |
6.2 测试类与成功路径
java
package example.order;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository repository;
@Mock
private PaymentGateway paymentGateway;
@InjectMocks
private OrderService service;
@Test
void pay_shouldChargeAndSavePaidOrder_whenOrderIsPending() {
Order pending = new Order(1L, 7L, new BigDecimal("99.00"), "PENDING");
when(repository.findById(1L)).thenReturn(Optional.of(pending));
when(repository.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0));
Order result = service.pay(1L);
assertThat(result).extracting(Order::status).isEqualTo("PAID");
verify(paymentGateway).charge(7L, new BigDecimal("99.00"));
ArgumentCaptor<Order> saved = ArgumentCaptor.forClass(Order.class);
verify(repository).save(saved.capture());
assertThat(saved.getValue())
.extracting(Order::id, Order::status)
.containsExactly(1L, "PAID");
}
这段测试逐句对应 AAA:
- Arrange :创建真实
Order,并规定 Repository 找到它;thenAnswer让保存方法返回传入对象,避免为每个状态再造一个无关 Mock。 - Act :只调用一次
service.pay(1L)。 - Assert 结果 :返回订单状态必须是
PAID。 - Assert 副作用 :支付网关必须收到用户和金额;Repository 保存的对象必须是订单 1 且状态为
PAID。
如果实现错误地保存了原订单、金额被改写或状态仍为 PENDING,该测试会失败;这比只写 verify(repository).save(any()) 强得多。
6.3 失败路径:订单不存在和重复支付
java
@Test
void pay_shouldRejectMissingOrder_withoutChargingOrSaving() {
when(repository.findById(9L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.pay(9L))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("订单不存在");
verify(paymentGateway, never()).charge(anyLong(), any());
verify(repository, never()).save(any(Order.class));
}
@Test
void pay_shouldRejectNonPendingOrder_withoutChargingAgain() {
Order paid = new Order(1L, 7L, new BigDecimal("99.00"), "PAID");
when(repository.findById(1L)).thenReturn(Optional.of(paid));
assertThatThrownBy(() -> service.pay(1L))
.isInstanceOf(IllegalStateException.class)
.hasMessage("订单不处于待支付状态");
verify(paymentGateway, never()).charge(anyLong(), any());
verify(repository, never()).save(any(Order.class));
}
}
失败测试的重点不是"异常被抛出"这一个断言,而是同时证明危险副作用没有发生 。如果订单不存在却调用了扣款网关,系统可能出现无法对账的真实损失;因此 never() 是业务断言,不是 Mockito 装饰。
6.4 依赖抛错时怎么写
java
@Test
void pay_shouldNotSavePaidOrder_whenPaymentGatewayFails() {
Order pending = new Order(1L, 7L, new BigDecimal("99.00"), "PENDING");
when(repository.findById(1L)).thenReturn(Optional.of(pending));
doThrow(new PaymentException("网关拒绝"))
.when(paymentGateway)
.charge(7L, new BigDecimal("99.00"));
assertThatThrownBy(() -> service.pay(1L))
.isInstanceOf(PaymentException.class)
.hasMessage("网关拒绝");
verify(repository, never()).save(any(Order.class));
}
这里使用 doThrow 是因为 charge 是 void 方法。测试证明的是当前方法不会在扣款失败后伪造 PAID 保存;事务回滚、支付幂等和重试策略仍需集成测试或契约测试补充。
7. 不同 Java 类应选择什么测试方法
7.1 工具类、解析器:不用 Spring,直接测输入输出
解析器应使用真实字符串和集合,覆盖空值、空白、重复、最大值、刚越界值以及错误消息。参数化测试可以减少重复,但每一组数据仍要对应一个明确规则。
java
@ParameterizedTest
@CsvSource({"200,true", "201,false"})
void acceptsOnlyAtMostTwoHundredItems(int count, boolean expected) {
assertThat(BatchSkuParser.isAllowedCount(count)).isEqualTo(expected);
}
7.2 Service/Engine:结果优先,交互补充
Mock Mapper、Repository、时钟、远程客户端和事件发布器;不要 Mock 值对象。验证状态迁移、返回值、异常、补偿和禁止路径。事务、锁、Redis TTL、多节点竞争不应被纯 Mockito 测试冒充为已验证。
7.3 Controller:直接调用、MockMvc、@WebMvcTest 分层
直接调用 Controller 适合 DTO 和响应头;MockMvcBuilders.standaloneSetup 适合局部路由;@WebMvcTest 适合 MVC、JSON 和安全切片;@SpringBootTest 才会加载完整上下文,但成本和外部依赖也更高。Controller 测试必须断言 HTTP 状态、错误码、响应字段和安全头,而不是只断言响应对象非空。
7.4 DAO/Mapper:文本契约和真实数据库分开
反射读取 @Select 并断言 LEFT JOIN、GROUP BY 等结构,只能防止关键 SQL 被误删;它不能证明 MySQL 方言、数据重复、索引或执行计划。真实 SQL 结论要在隔离数据库中运行迁移、插入最小数据并检查结果与 EXPLAIN。
7.5 异步与重试:验证状态机,不要靠大超时
优先注入可控 Executor、Clock、Sleeper 或测试队列。若确实需要等待,使用短 timeout 并同时断言最终状态、提交次数和失败补偿。timeout(30_000) 只会让竞态更慢暴露,不能让异步逻辑变可靠。
8. 项目源码对应关系:引用位置说明了什么
以下位置是现有文档中引用的项目证据,读者可按路径核对;它们说明项目采用了哪些测试方式,不代表每个生产边界都已覆盖。
| 位置 | 观察到的测试方法 | 可支持的判断 |
|---|---|---|
src/test/java/com/aiimage/service/BatchSkuParserTest.java:9-35 |
纯 Java、AssertJ、边界输入 | 解析结果、去重和上限规则可快速回归 |
src/test/java/com/aiimage/auth/AuthControllerTest.java:25-116 |
直接调用 Controller、响应头断言 | DTO 和安全响应契约可局部验证 |
src/test/java/com/aiimage/engine/PromptAssemblerTest.java:21-140,183-190 |
@ExtendWith、@Mock、@InjectMocks、@MethodSource |
参数化提示词规则无需启动 Spring |
src/test/java/com/aiimage/engine/TaskEngineTest.java:58-147 |
Mockito、never()、异步提交验证 |
领取失败或禁止路径不提交任务 |
src/test/java/com/aiimage/engine/TaskExecutorTest.java:157-254 |
ArgumentCaptor、InOrder、补偿断言 |
跨边界写入顺序和失败收敛有局部证据 |
src/test/java/com/aiimage/dao/TaskMapperQueryContractTest.java:10-22 |
反射读取 SQL 注解 | SQL 文本结构有轻量护栏,未证明真实执行 |
src/test/java/com/aiimage/security/SecurityConfigTest.java:40-118 |
Spring 安全切片 | 未认证和 Bearer 请求的局部行为可验证 |
src/test/java/com/aiimage/service/PromptGenerationDevManualIT.java:15-44 |
@SpringBootTest、条件启用、真实开发服务 |
受控手工集成验证,不属于日常快速单测 |
9. 常见错误:为什么测试会绿却不可信
| 错误写法 | 表面现象 | 修正方式 |
|---|---|---|
只断言 isNotNull() |
错误对象也能通过 | 断言状态、字段、集合元素和错误码 |
只写 verify(save(any())) |
任意错误数据都能通过 | 用 ArgumentCaptor 检查关键字段 |
| Mock 所有对象 | 测试只验证 Mock 配置 | 值对象和规则对象使用真实实例 |
| 只测成功分支 | 线上异常路径未覆盖 | 增加空、非法、边界、依赖失败、重复调用 |
| 一个测试验证多个业务动作 | 失败定位困难 | 一个测试一个 Act;相关字段用 assertAll |
用 @SpringBootTest 测所有方法 |
慢、依赖多、失败难定位 | 规则用纯单元,Web 用切片,连接用集成 |
| 为测私有方法把它改成 public | 暴露实现细节 | 测公共行为;必要时提取协作者 |
用超长 timeout 等待 |
偶现失败被掩盖 | 注入可控调度器、时钟和队列 |
| 把集成测试叫单元测试 | 读者误判覆盖边界 | 按真实依赖范围命名和执行 |
10. 执行命令、验证边界与排错
在 Maven 项目模块目录执行:
bash
# 运行默认单元测试
mvn test
# 运行指定测试类
mvn -Dtest=BatchSkuParserTest test
# 运行指定测试方法
mvn -Dtest=BatchSkuParserTest#rejectsMoreThanTwoHundredDistinctSkus test
# 仅编译并打包,跳过测试执行
mvn -DskipTests package
mvn test 的成功证据是 Surefire 报告中指定测试实际执行且 failures/errors 为 0;-DskipTests package 只能证明编译和打包链路。测试失败时按顺序检查:断言是否表达业务规则、fixture 是否构造目标状态、Mock matcher 是否过宽、异步/全局状态是否泄漏,最后才判断生产代码。
| 层次 | 已证明 | 未证明 | 下一步 |
|---|---|---|---|
| 示例代码 | 展示了从方法契约到 AAA、结果断言和副作用断言的完整写法 | 本文目录未编译示例,依赖的 Order、Repository 等接口需补齐 |
放入 Maven 模块后运行指定测试 |
| 项目引用 | 现有源码路径展示了项目使用的 JUnit、Mockito、AssertJ 和 Spring 测试方式 | 未由这些引用推出真实数据库、Redis、OSS 或第三方服务可用 | 在隔离环境追加集成和冒烟验证 |
| 构建命令 | 说明 Maven 如何收窄测试范围 | 未执行当前项目全量测试,不能宣称全绿 | 在目标模块执行 mvn test 并保存报告 |
11. 最终判断:一份测试是否值得信任
读完测试,应该能回答四个问题:给定什么条件?方法承诺什么结果?哪些副作用必须发生或禁止发生?失败时系统如何收敛?如果只能看到"某个方法被调用",却看不到业务结果和失败语义,测试仍然不完整。
推荐的最小落地顺序是:先为公共方法写成功、空值/非法、边界、依赖失败和重复调用场景;再只替换数据库、网络、时钟等边界依赖;最后用 mvn -Dtest=类名#方法名 test 运行并阅读失败报告。随着风险增加,再补 @WebMvcTest、真实数据库集成测试和受控端到端验证。测试层级越宽,能证明的协作越多,但运行成本和环境变量也越多,不能相互替代。
附录:测试 API 速查表
| 目的 | 首选 API |
|---|---|
| 初始化 Mockito | @ExtendWith(MockitoExtension.class) |
| 创建依赖替身 | @Mock、mock() |
| 注入被测对象 | @InjectMocks 或显式构造器 |
| 配置返回值 | when(...).thenReturn(...) |
| 配置异常 | thenThrow(...)、doThrow(...).when(...) |
| 结果断言 | AssertJ assertThat(...) |
| 异常断言 | assertThrows、assertThatThrownBy |
| 禁止交互 | verify(mock, never()) |
| 精确参数 | eq(...) |
| 捕获写入对象 | ArgumentCaptor |
| 验证顺序 | InOrder |
| 多组输入 | @ParameterizedTest、@CsvSource、@MethodSource |
| Web 层 | @WebMvcTest、MockMvc |
| 集成层 | @SpringBootTest |
结论:Java 测试用例应从方法契约和场景矩阵出发,用 AAA 组织一次业务动作,用 AssertJ 证明结果,用 Mockito 只隔离边界并验证必要副作用,再用 Spring/数据库集成测试补足单元测试无法证明的运行环境。