在进行单元测试时,务必覆盖所有 catch 块,尤其是边界异常处理场景。核心思路只有一个:**不要被动等待异常自动出现,而应主动将其“诱发”出来**。借助可控手段——例如模拟对象、构造边界参数、替换依赖组件——确保代码路径精准进入每个 catch 分支,随后验证异常处理逻辑是否正确执行。

简而言之,这并非高深的技术难题,而是一套系统化的排查与执行方法。下面我们将步骤逐一拆解。
清晰定位每个 catch 块的异常来源与触发条件
第一步,先全面梳理被测方法中的所有 try-catch 结构,逐条明确:
- 异常来源究竟是什么? 是内部调用(如
service.doSomething())抛出的,还是参数校验(如Objects.requireNonNull())主动抛出?亦或是 I/O、网络等外部依赖引发的? - 该异常由哪个 catch 负责捕获? 这里容易踩坑:子类异常可能被父类
catch提前“截取”。务必检查异常继承关系,避免误以为某条路径未被覆盖——其实已被其他分支处理。 - catch 块内具体执行了哪些操作? 是返回默认值、记录日志、转换为另一异常,还是默默重试?这些行为必须能被测试验证——例如检查返回值、确认 mock 的 logger 是否被调用、或断言某字段状态已变更。
借助 Mock 或 Stub 主动注入异常
针对依赖外部组件(数据库、HTTP 客户端、文件系统等)的 catch 块,最直接的方法是使用 Mockito、MockitoExtension 或 TestContainers 等工具,让依赖在特定调用时精准抛出所需异常。
- 举例:mock 某个 service 方法,令其在第二次调用时抛出
NullPointerException,然后验证空指针的兜底逻辑是否按预期生效。 - 再 mock 同一方法,使其抛出
IOException,验证 IO 异常分支的处理流程。 - 此处需注意 checked 与 unchecked 异常的区别:前者编译时强制处理,后者则需确保运行时能到达——例如传入 null 或非法对象。
构造边界输入直接触发参数或校验异常
许多 catch 块来源于方法内部的显式 throw,例如下述情形:
if (id <= 0) throw new IllegalArgumentException("id must be positive");
此类场景完全无需 mock 外部依赖,只需传入边界值即可实现覆盖:
- 传入
id = 0或id = -1,触发IllegalArgumentException。 - 传入
null字符串,触发NullPointerException(前提是未使用@NonNull等注解提前拦截)。 - 传入超长字符串,触发自定义的长度校验异常。
验证 catch 行为,不止于“是否抛异常”
覆盖 ≠ 代码到达 catch 块即可,关键在于**异常处理逻辑是否正确执行业务需求**。常见验证要点包括:
- 返回值是否符合预期? 例如 catch 后返回
Optional.empty()或默认对象,用assertThat(result).isEmpty()断言。 - 状态是否已更新? 比如设置了失败标志、递增了重试计数器,则断言对应字段的取值。
- 日志是否被记录? 利用
LogCaptor或 mock logger,验证日志级别与关键信息是否准确。 - 是否抛出了新异常? 若 catch 块内又 throw 了一个包装异常(如
throw new ServiceException(e)),则用assertThrows捕获并验证其根本原因。(...)
虽不复杂,但容易忽略的是:每个 catch 都应对应一个明确的测试用例。命名时可清晰体现意图,例如 shouldReturnEmptyWhenDatabaseThrowsSQLException,这样测试失败时能一目了然地定位是哪条异常路径未覆盖。
