读完这篇,你能做什么
能为一个纯业务方法覆盖成功、拒绝与异常路径,并说明模拟测试没有验证什么。
学习理由、准备要求与知识点
为什么学它
单元测试把业务规则变成可重复验证的案例,也为后续重构提供即时反馈。
先读这些内容
知识点清单
- Arrange Act Assert
- 正常与异常分支
- 边界输入
- JUnit 断言
- Mockito stubbing
- 关键交互 verify
- 测试替身边界
- 避免过度模拟
运行前先确认Java 17;框架讲解固定 JUnit 5.12.2 与 Mockito 5.17.0。JUnit AssertThrows 源码按 r5.12.2 核验。完整算法使用 JDK 自测与内存替身,不包含真实数据库、网络或 Mockito 引擎集成测试。
01先弄懂它是什么
有用的测试应该能抓住业务错误,而不是照抄实现。例如预留库存:数量必须为正,同一商品重复出现要合并数量,库存不够时必须拒绝且不能扣减。先写出这些输入、结果和不能发生的副作用,再写测试和实现。若测试只把代码逻辑换一种写法重述,可能连原来的错误也一起复制。
JUnit 负责发现和运行测试,并提供断言;Mockito 负责制作可控制行为的替身。JUnit Jupiter 的 @Test 标记测试,assertEquals 比较结果,assertThrows 检查异常;Mockito 的 when 设定依赖会怎样回答,verify 核对关键调用。但模拟对象说成功,只证明这次设定下业务如何处理,不证明真实 SQL、事务或网络调用成功。
测试中替代真实依赖的对象统称测试替身,但用法不同。Stub 直接给预设答案,例如固定返回“库存不足”;fake 提供一个简化但能工作的实现,例如内存仓库;spy 基于真实对象创建,没有替换掉的行为可能执行真实方法。按需要选最简单的方式:纯计算直接测试,不必模拟;需要确认非法请求绝不访问网关时,再用 mock 检查有没有发生这次调用。
文中这几个词,先认一认
- 行为断言
- 检查调用者能看到的结果、异常或副作用是否符合业务规则。
- 测试替身
- 在测试里替代真实依赖的受控对象,不代表它拥有真实数据库或服务的全部行为。
- 边界案例
- 规则刚好切换时的输入,例如 0、库存恰好足够、空批次或刚好溢出的数。
02看看它怎么工作
将用例写成准备、执行、断言三部分。准备只构造本用例必要状态,执行聚焦一个业务动作,断言覆盖结果与相关副作用。对于数量零,既要 assertThrows(IllegalArgumentException.class, ...),也要核对库存网关没有被调用,因为仅仅最终抛异常可能发生在已经扣库存之后。
JUnit 5.12.2 的 assertThrows 接受预期类型的子类,而 assertThrowsExactly 要求精确类型;返回的异常可以继续用于检查业务错误码和原因。不要把被测方法调用放到断言之外再包一个空 lambda,否则实际失败路径没有被测试。异常消息只有在它属于稳定契约时才精确比较,避免标点调整导致大量无意义失败。
Mockito 可用 mock(StockGateway.class) 构造依赖,通过 when(stock.reserve(...)).thenReturn(false) 模拟不足,再 verifyNoInteractions 或 verify 约束关键行为。
匹配器要遵守 API 的组合规则;spy 的 when 调用可能先执行真实方法,必要时使用适合替身语义的 doReturn 方式。不要把服务内部每个私有步骤都变成模拟点,否则实现重构会使测试脆弱。
Java 17 项目可在构建文件中固定 junit-jupiter 与 mockito-core 的测试依赖,并配置能发现 Jupiter 的测试运行器。通过 Maven 时检查 mvn test 的实际测试数量,零测试且命令成功不代表验证了行为。完整算法先用不依赖框架的 main 自测表达相同业务契约,迁移到 JUnit 时把每个场景拆为独立命名的方法即可。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["明确业务规则与失败状态"] --> B["准备输入和受控协作者"]
B --> C["调用被测服务"]
C --> D["断言结果与异常"]
D --> E["检查必要或禁止的副作用"]
E --> F["真实边界另做集成测试"]按顺序看,每一步做了什么
- 写清成功、拒绝和错误输入的契约。
- 选择正常、空、边界和异常案例。
- 只替换真正的外部协作者。
- 检查结果及关键副作用。
- 核对测试发现数量,并单独验证真实边界。
03对照实现代码
JUnit Jupiter 5.12.2;标签 r5.12.2;AssertThrows.java · org.junit.jupiter.api.AssertThrows#assertThrows(Class,Executable)
以下为原创 Java 17 局部异常断言模型,不是原样 JUnit 源码;省略报告构造、消息供应器、平台工具和完整不可恢复错误策略。真实定位见 JUnit 5.12.2 链接。
public final class SourceModel {
@FunctionalInterface interface Action { void run() throws Throwable; }
static <T extends Throwable> T expect(Class<T> type,Action action) {
try { action.run(); }
catch(Throwable actual) {
if(type.isInstance(actual)) return type.cast(actual); // 接受预期类型的子类。
if(actual instanceof VirtualMachineError fatal) throw fatal; // 教学模型不吞掉虚拟机致命错误。
throw new AssertionError("unexpected exception",actual); // 保存原始原因,便于定位。
}
throw new AssertionError("nothing thrown"); // 没有异常也是断言失败。
}
}JUnit r5.12.2 的 AssertThrows 执行 Executable,捕获实际异常,再用 expectedType.isInstance 判断是否属于预期类型,匹配时返回这个异常。因此子类也会被接受;若只断言 RuntimeException,意外的 NullPointerException 也可能让测试通过,要选择足够准确的预期。
异常类型不匹配时,真实实现通过 AssertionFailureBuilder 构造带预期、实际与原因的失败信息,并处理不可恢复异常;完全不抛异常则形成另一种失败。保留 cause 让报告能够指向被测行为,而不是只看到一条泛泛的类型错误。测试失败信息本身也是排障接口,需要明确而稳定。
教学模型是原创的局部断言器,不是原样 JUnit 源码,省略消息供应器、报告格式、平台工具和完整不可恢复错误策略。它只用于说明三条关键路径:匹配、类型不符、没有抛出。实际项目应使用成熟断言库,不应为了运行一个示例而替换项目测试基础设施。
读取断言实现也能发现测试设计问题:若 lambda 同时做数据准备、网关调用和结果解析,任何一步抛出符合类型的异常都可能让测试通过。应尽量把准备放在断言外,让被断言动作只包含希望验证的业务入口,并继续检查状态确实没有发生不该有的变化。
代码先调用谁,再调用谁
Assertions.assertThrows公开入口AssertThrows执行动作- 捕获实际异常
isInstance检查预期类型- 返回异常或构造断言失败
04动手写一个例子
可测试的批量库存预留计划
正数量的商品行先聚合为每商品需求,再一次调用网关;非法输入和总量溢出不得调用网关。内存 fake 模拟整批足够才扣减的契约,完整 Java17 程序含明确自测。
- 输入 1
商品 7 数量 2 和 3,库存 5- 输出 1
成功且库存变为 0
- 输入 2
任一数量为 0- 输出 2
异常且网关调用数不变
逐行调用库存网关会让早期合法行先产生副作用,直到后项非法时才报错。算法先在内存中验证全部行并聚合需求,使用 addExact 检查同商品数量总和是否溢出,然后只调用一次网关。计划构建和外部执行之间形成清晰可测试的边界。
网关契约要求整批不足时不扣减,fake 先检查所有商品,再统一扣除,供本地测试验证服务逻辑。真实数据库实现必须用自己的事务、锁或条件更新满足同样约定;内存 fake 通过不能证明真实实现拥有并发原子性。服务也不能仅靠一次方法调用就自动获得该保证。
测试分别观察结果、调用次数和剩余库存。非法数量与聚合溢出应在零调用时失败;库存不足调用一次但状态不变;重复商品成功只消耗聚合后的总量;空批按契约直接成功且无需访问网关。这样每个测试都覆盖一个容易发生行为分歧的边界。
为什么这样写是对的调用网关之前,计划中每个数量都为正、同商品唯一且总和未溢出;输入验证失败没有外部调用。
每行处理一次,fake 对 k 项检查与扣减各一次;真实网关成本需另加。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
record Line(long sku,int quantity) {}
interface Gateway { boolean reserve(Map<Long,Integer> plan); }
static boolean reserve(List<Line> lines,Gateway gateway) {
Map<Long,Integer> plan=new HashMap<>();
for(Line line:lines) {
if(line.sku()<=0 || line.quantity()<=0) throw new IllegalArgumentException("invalid line");
plan.merge(line.sku(),line.quantity(),Math::addExact); // 聚合阶段发现溢出,尚未调用外部依赖。
}
if(plan.isEmpty()) return true;
return gateway.reserve(Map.copyOf(plan)); // 只把不可修改计划交给一次网关调用。
}
static class Fake implements Gateway {
final Map<Long,Integer> stock=new HashMap<>(); int calls;
Fake(int amount) {stock.put(7L,amount);}
public boolean reserve(Map<Long,Integer> plan) {
calls++;
for(var e:plan.entrySet()) if(stock.getOrDefault(e.getKey(),0)<e.getValue()) return false;
for(var e:plan.entrySet()) stock.put(e.getKey(),stock.get(e.getKey())-e.getValue()); // 检查完再扣减。
return true; // 此 fake 仅供单线程测试,不声明数据库事务能力。
}
}
static void eq(Object a,Object b) {if(!Objects.equals(a,b)) throw new AssertionError(a+" != "+b);}
static void fails(Class<? extends Throwable> t,Runnable r) {
try {r.run();} catch(Throwable e) {if(t.isInstance(e)) return; throw new AssertionError(e);}
throw new AssertionError("expected "+t);
}
public static void main(String[] args) {
Fake ok=new Fake(5); eq(reserve(List.of(new Line(7,2),new Line(7,3)),ok),true); eq(ok.stock.get(7L),0); eq(ok.calls,1);
Fake invalid=new Fake(5); fails(IllegalArgumentException.class,()->reserve(List.of(new Line(7,0)),invalid)); eq(invalid.calls,0);
Fake shortStock=new Fake(4); eq(reserve(List.of(new Line(7,5)),shortStock),false); eq(shortStock.stock.get(7L),4);
Fake empty=new Fake(5); eq(reserve(List.of(),empty),true); eq(empty.calls,0);
Fake overflow=new Fake(5); fails(ArithmeticException.class,()->reserve(List.of(new Line(7,Integer.MAX_VALUE),new Line(7,1)),overflow)); eq(overflow.calls,0);
Fake negative=new Fake(5); fails(IllegalArgumentException.class,()->reserve(List.of(new Line(7,-1)),negative)); eq(negative.calls,0);
Gateway broken=p->{throw new IllegalStateException("offline");};
fails(IllegalStateException.class,()->reserve(List.of(new Line(7,1)),broken));
System.out.println("PASS 7");
}
}这些特殊情况也要试一试
- 空批无需网关调用。
- 零或负数量在副作用前拒绝。
- 聚合整数溢出明确失败。
- 网关异常传播,不伪装成库存不足。
进阶选读 · 契约测试与并发集成测试
场景fake 与真实数据库实现逐渐出现语义偏差。
做法让同一组网关契约用例分别运行在 fake 和隔离真实实现上,再加入并发争抢、事务失败与重试场景。
复杂度执行成本由基础设施启动和交错次数决定,有限样例不能穷尽全部调度。
好处与代价证据更接近真实边界,但环境隔离和可重复性需要额外工程。
05项目里怎么用
单元测试应当快速、确定且表达业务意图。时间、随机数和外部网络可以通过明确接口注入,固定输入后便于重放失败;这不意味着把所有类都接口化,而是识别真正导致不确定性的边界。测试命名描述条件和结果,失败报告才能直接帮助定位,而不是只显示 test1 出错。
把每个内部调用顺序都 verify 一遍,会让实现很难调整。只有顺序会影响业务正确性时才重点检查,例如校验必须先于扣款。可见结果、保存状态和禁止发生的动作通常更稳定。实现改动后测试失败,要分清是业务约定变了,还是测试绑得过细,不能只为通过就删断言。
测试分层依赖要证明的事实:纯规则用单元测试,JSON 绑定与请求过滤需要对应 Web 测试,SQL、唯一约束和事务交错要靠隔离数据库验证。覆盖率可以指出未执行区域,却不证明断言足够强。可通过有意识地改变一个比较符或移除校验,检查用例是否会失败,评估测试对关键错误的敏感度。
容易踩的坑
- 只检查抛异常,不检查此前是否已经扣减。
- 宽泛异常类型让无关错误误通过。
- 模拟所有内部方法导致重构困难。
- 测试报告零用例仍被误认为已验证。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 纯函数测试 | 快速、定位清楚 | 不覆盖基础设施 | 计算与校验规则 |
| mock 隔离边界 | 易制造失败并检查关键交互 | 依赖设置可能偏离真实契约 | 外部协作者行为 |
| 真实集成测试 | 验证 SQL、协议和配置 | 较慢且需隔离环境 | 跨系统边界 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01assertThrows 是否接受子类?
展开参考答案与推导
JUnit 5.12.2 的该断言使用 isInstance,因此接受子类。若业务要求精确类型,可用 assertThrowsExactly,或对返回异常做进一步稳定断言。
02mock 成功返回证明了什么?
展开参考答案与推导
证明在该受控返回条件下,被测代码产生了断言中的结果。它没有执行真实数据库或网络,不能据此断言实际事务、约束和超时配置正确。
03为什么不对所有内部调用 verify?
展开参考答案与推导
内部步骤经常因等价重构改变,过度交互断言会冻结实现。应优先检查契约结果,只验证影响业务正确性的关键或禁止副作用。
换个情况,还会处理吗
01非法数量抛错测试通过,但库存已扣怎么办?
展开参考答案与推导
增加网关无调用或状态不变断言,并将整批输入验证前移到外部操作之前。异常只是结果之一,失败时允许的状态同样属于业务契约。
02本地 test 成功却没有发现用例怎么办?
展开参考答案与推导
检查实际执行数量、Jupiter 引擎与构建运行器配置、测试路径和命名规则。先让一个故意失败的用例被发现,再恢复正确期望确认闭环。
03fake 通过而真实实现部分扣减怎么办?
展开参考答案与推导
说明真实网关没有满足整批拒绝不修改的契约。应在真实实现中设计事务或条件更新,并用相同契约和故障场景复核,不能调整 fake 去隐藏差异。
写代码试一试
01为零数量写关键断言。
- 输入 / 条件
reserve(7,0)- 输出 / 预期
异常且零网关调用
提示 结果与副作用同时检查。
展开参考答案与推导
JUnit 中用 assertThrows 验证异常,再用 Mockito.verifyNoInteractions(stock) 检查边界未被访问;被测服务应先校验数量。
02重复商品如何避免漏算?
- 输入 / 条件
7:2、7:3- 输出 / 预期
7:5
提示 聚合后只调用一次。
展开参考答案与推导
用映射按商品合并数量,并使用 addExact 检查溢出。不能让后一个 put 覆盖前一项,否则测试只有不同商品时很难暴露问题。
03错误类型断言如何避免假通过?
- 输入 / 条件
期望业务拒绝却实际 NPE- 输出 / 预期
测试失败
提示 不要只断言 RuntimeException。
展开参考答案与推导
选择具体的业务异常或稳定错误码,并将 lambda 缩小为业务调用。过宽类型可能将空指针缺陷误认为预期拒绝。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能为一个纯业务方法覆盖成功、拒绝与异常路径,并说明模拟测试没有验证什么。
本篇重点加练
断言要验证业务性质,测试替身只能提供受其模型约束的证据。
- JUnit 断言
- 测试替身边界
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
优惠计算函数根据会员等级、购物金额和券状态返回结果。把测试分为准备、执行、断言,列出正常路径与失败路径各应观察什么。
- 提交内容
- 测试用例矩阵
- 怎样算完成
- 每例只执行明确行为
- 失败用例也检查业务状态
练到的知识点Arrange Act Assert · 正常与异常分支
- 作业 02代码推演
价格以分计,数量允许 1~100。选择边界与越界样本,评审只断言“结果非空”的测试,并设计能暴露错误计算的断言口径。
- 提交内容
- 样本与断言说明
- 怎样算完成
- 覆盖金额范围和输入边界
- 不依赖测试实现内部细节
练到的知识点边界输入 · JUnit 断言
- 作业 03面试场景
同一单元测试用 mock、内存 fake 和真实数据库各运行一次。解释三种证据分别能覆盖的边界,以及哪些测试不宜继续增加模拟层。
- 提交内容
- 证据分级表
- 怎样算完成
- 区分业务分支与数据库约束
- 说明维护成本
练到的知识点测试替身边界 · 避免过度模拟
- 作业 04原理问答
一个排序测试只断言列表长度不变,另一个测试只断言结果非空。为两者定义应验证的性质,并说明性质之间是否互相蕴含。
- 提交内容
- 断言性质表
- 怎样算完成
- 包含元素保留与排序关系
- 不直接复制实现内部比较流程
练到的知识点JUnit 断言
综合应用
- 作业 05设计评审
下单服务需要查询库存并登记订单。设计替身配置与关键交互核验,说明哪些调用次数属于业务规则,哪些只是实现细节。
- 提交内容
- 替身契约与核验表
- 怎样算完成
- 包含依赖抛错及拒绝返回
- 不验证无关 getter 调用
练到的知识点Mockito stubbing · 关键交互 verify
- 作业 06编程实践
实现最小优惠结算模块与 JUnit 测试,主动植入三类缺陷后记录哪些用例能发现;依赖全部为本地替身。
- 提交内容
- 工程、测试报告与缺陷对照
- 怎样算完成
- 每个缺陷对应可观察行为
- 确认测试确实被发现并执行
练到的知识点Arrange Act Assert · 正常与异常分支 · 边界输入 · JUnit 断言 · Mockito stubbing · 关键交互 verify · 测试替身边界 · 避免过度模拟
- 作业 07设计评审
内存 fake 的事务失败会自动恢复所有字段,而真实依赖的部分操作不在事务内。制定替身符合性检查,说明如何识别过强的 fake 保证。
- 提交内容
- 替身协议与证据边界
- 怎样算完成
- 核对失败路径
- 不以单元测试通过替代真实依赖语义
练到的知识点测试替身边界
- 作业 08编程实践
为本地金额聚合模块编写变形性质测试,如改变输入顺序和分批合并,使用同一性质检查真实实现与故意有缺陷的 fake。
- 提交内容
- 测试代码及缺陷发现记录
- 怎样算完成
- 先声明性质适用条件
- 涉及舍入时不盲目假定代数律成立
练到的知识点JUnit 断言 · 测试替身边界
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
重构后业务行为不变,却有大量验证内部调用顺序的测试失败。选取代表用例重写测试目标,提出判断保留或删除断言的依据。
- 提交内容
- 测试评审记录
- 怎样算完成
- 保留业务关键交互
- 比较重构前后可验证的外部行为
练到的知识点正常与异常分支 · JUnit 断言 · 关键交互 verify · 测试替身边界 · 避免过度模拟
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 开放探究
没有可靠参考答案时,如何检验推荐评分函数的局部性质?提出可证伪的测试关系,并说明哪些关系属于需求假设而非数学必然。
- 提交内容
- 开放测试方案
- 怎样算完成
- 至少一项假设可被业务反例推翻
- 不将性质测试称为完整正确性证明
练到的知识点JUnit 断言 · 测试替身边界
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读教程英文
JUnit 5.12.2 User Guide (新标签页)↗
阅读断言、参数化测试和生命周期,编写可重复的正常与异常路径测试。
- 实践参考文档英文
Mockito Java API (新标签页)↗
阅读 stubbing、verify 与参数匹配,模拟边界依赖并验证关键交互。
- 深入参考文档英文
Spring Boot 3.5:Testing (新标签页)↗
阅读测试切片、完整应用测试与事务注意事项,选择真实覆盖目标的测试范围。
- 实践实践指南中文
第一个 JUnit 测试:把正确结果写成自动检查 (新标签页)↗
先学创建测试类和运行测试。小练习:给求和方法写普通输入、空输入、负数三个测试,故意写错一个期望值,读懂失败提示后再修正。 文档公开;示例含较旧的 JUnit 版本和 Java 21 设置。可学习操作步骤,依赖版本应与自己的项目匹配。
- 实践实践指南英文
Spring:给控制器写第一组测试 (新标签页)↗
先读
contextLoads,再读 @WebMvcTest与服务替身示例;暂跳 Kotlin 和真实端口测试。给同一控制器写正常返回、服务返回异常两组检查,交出测试结果和一段说明:本次到底加载了哪些组件。 网页公开可读,无需登录。运行需要匹配项目版本的 JDK 和构建工具;当前示例的测试包名与旧版 Spring Boot 不完全相同,先核对自己的版本。 - 深入参考文档英文
JUnit AssertThrows:异常断言如何判定成功 (新标签页)↗
核对执行、类型匹配、未抛出和错误类型的三条断言路径。
- 深入参考文档英文
Mockito 5.17.0 API (新标签页)↗
阅读 mock、stub、verify、spy 与匹配器边界。