qinghuan
← 工程实践 / 全部笔记

单元 50 / 工程实践

P0 · 必会主线L1 · 基础

JUnit 与 Mockito:把业务规则写成有意义的单元测试

先列业务必须满足的结果和拒绝条件,再用 JUnit 断言与 Mockito 替身检查,明确模拟测试能证明什么。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能为一个纯业务方法覆盖成功、拒绝与异常路径,并说明模拟测试没有验证什么。

学习理由、准备要求与知识点

为什么学它

单元测试把业务规则变成可重复验证的案例,也为后续重构提供即时反馈。

先读这些内容

知识点清单

  • 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["真实边界另做集成测试"]

按顺序看,每一步做了什么

  1. 写清成功、拒绝和错误输入的契约。
  2. 选择正常、空、边界和异常案例。
  3. 只替换真正的外部协作者。
  4. 检查结果及关键副作用。
  5. 核对测试发现数量,并单独验证真实边界。

03对照实现代码

JUnit AssertThrows:异常断言如何判定成功 (新标签页)↗

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 链接。

核心机制代码 · java
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 同时做数据准备、网关调用和结果解析,任何一步抛出符合类型的异常都可能让测试通过。应尽量把准备放在断言外,让被断言动作只包含希望验证的业务入口,并继续检查状态确实没有发生不该有的变化。

代码先调用谁,再调用谁

  1. Assertions.assertThrows 公开入口
  2. AssertThrows 执行动作
  3. 捕获实际异常
  4. isInstance 检查预期类型
  5. 返回异常或构造断言失败

04动手写一个例子

可测试的批量库存预留计划

正数量的商品行先聚合为每商品需求,再一次调用网关;非法输入和总量溢出不得调用网关。内存 fake 模拟整批足够才扣减的契约,完整 Java17 程序含明确自测。

输入 1
商品 7 数量 2 和 3,库存 5
输出 1
成功且库存变为 0
输入 2
任一数量为 0
输出 2
异常且网关调用数不变

逐行调用库存网关会让早期合法行先产生副作用,直到后项非法时才报错。算法先在内存中验证全部行并聚合需求,使用 addExact 检查同商品数量总和是否溢出,然后只调用一次网关。计划构建和外部执行之间形成清晰可测试的边界。

网关契约要求整批不足时不扣减,fake 先检查所有商品,再统一扣除,供本地测试验证服务逻辑。真实数据库实现必须用自己的事务、锁或条件更新满足同样约定;内存 fake 通过不能证明真实实现拥有并发原子性。服务也不能仅靠一次方法调用就自动获得该保证。

测试分别观察结果、调用次数和剩余库存。非法数量与聚合溢出应在零调用时失败;库存不足调用一次但状态不变;重复商品成功只消耗聚合后的总量;空批按契约直接成功且无需访问网关。这样每个测试都覆盖一个容易发生行为分歧的边界。

为什么这样写是对的调用网关之前,计划中每个数量都为正、同商品唯一且总和未溢出;输入验证失败没有外部调用。

时间 O(n+k),n 为行数,k 为商品数,散列操作取预期成本。额外空间 O(k) 的聚合计划。

每行处理一次,fake 对 k 项检查与扣减各一次;真实网关成本需另加。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 40 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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 断言
  • 测试替身边界

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    优惠计算函数根据会员等级、购物金额和券状态返回结果。把测试分为准备、执行、断言,列出正常路径与失败路径各应观察什么。

    提交内容
    测试用例矩阵
    怎样算完成
    • 每例只执行明确行为
    • 失败用例也检查业务状态

    练到的知识点Arrange Act Assert · 正常与异常分支

  2. 作业 02代码推演

    价格以分计,数量允许 1~100。选择边界与越界样本,评审只断言“结果非空”的测试,并设计能暴露错误计算的断言口径。

    提交内容
    样本与断言说明
    怎样算完成
    • 覆盖金额范围和输入边界
    • 不依赖测试实现内部细节

    练到的知识点边界输入 · JUnit 断言

  3. 作业 03面试场景

    同一单元测试用 mock、内存 fake 和真实数据库各运行一次。解释三种证据分别能覆盖的边界,以及哪些测试不宜继续增加模拟层。

    提交内容
    证据分级表
    怎样算完成
    • 区分业务分支与数据库约束
    • 说明维护成本

    练到的知识点测试替身边界 · 避免过度模拟

  4. 作业 04原理问答

    一个排序测试只断言列表长度不变,另一个测试只断言结果非空。为两者定义应验证的性质,并说明性质之间是否互相蕴含。

    提交内容
    断言性质表
    怎样算完成
    • 包含元素保留与排序关系
    • 不直接复制实现内部比较流程

    练到的知识点JUnit 断言

综合应用

  1. 作业 05设计评审

    下单服务需要查询库存并登记订单。设计替身配置与关键交互核验,说明哪些调用次数属于业务规则,哪些只是实现细节。

    提交内容
    替身契约与核验表
    怎样算完成
    • 包含依赖抛错及拒绝返回
    • 不验证无关 getter 调用

    练到的知识点Mockito stubbing · 关键交互 verify

  2. 作业 06编程实践

    实现最小优惠结算模块与 JUnit 测试,主动植入三类缺陷后记录哪些用例能发现;依赖全部为本地替身。

    提交内容
    工程、测试报告与缺陷对照
    怎样算完成
    • 每个缺陷对应可观察行为
    • 确认测试确实被发现并执行

    练到的知识点Arrange Act Assert · 正常与异常分支 · 边界输入 · JUnit 断言 · Mockito stubbing · 关键交互 verify · 测试替身边界 · 避免过度模拟

  3. 作业 07设计评审

    内存 fake 的事务失败会自动恢复所有字段,而真实依赖的部分操作不在事务内。制定替身符合性检查,说明如何识别过强的 fake 保证。

    提交内容
    替身协议与证据边界
    怎样算完成
    • 核对失败路径
    • 不以单元测试通过替代真实依赖语义

    练到的知识点测试替身边界

  4. 作业 08编程实践

    为本地金额聚合模块编写变形性质测试,如改变输入顺序和分批合并,使用同一性质检查真实实现与故意有缺陷的 fake。

    提交内容
    测试代码及缺陷发现记录
    怎样算完成
    • 先声明性质适用条件
    • 涉及舍入时不盲目假定代数律成立

    练到的知识点JUnit 断言 · 测试替身边界

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    重构后业务行为不变,却有大量验证内部调用顺序的测试失败。选取代表用例重写测试目标,提出判断保留或删除断言的依据。

    提交内容
    测试评审记录
    怎样算完成
    • 保留业务关键交互
    • 比较重构前后可验证的外部行为

    练到的知识点正常与异常分支 · JUnit 断言 · 关键交互 verify · 测试替身边界 · 避免过度模拟

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 开放探究

    没有可靠参考答案时,如何检验推荐评分函数的局部性质?提出可证伪的测试关系,并说明哪些关系属于需求假设而非数学必然。

    提交内容
    开放测试方案
    怎样算完成
    • 至少一项假设可被业务反例推翻
    • 不将性质测试称为完整正确性证明

    练到的知识点JUnit 断言 · 测试替身边界

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗