测试跑完,CI 绿灯,报表上写着成百上千个用例通过。可这个数字回答不了三个更值钱的问题:这批用例是不是全量、有没有用例其实一行代码都没执行、”通过”是框架判定的还是统计脚本猜的。差别的代价往往要等一次线上事故才被翻出来——某个本该拦住空指针的用例被标签过滤掉了一整个迭代,而汇总逻辑把”没跑”写进了”通过”。

把测试结局拆成五态:通过、失败、中止、跳过、未运行

要把”通过”讲清楚,可靠的办法只有一个:把它翻译成框架的扩展点——谁在什么时候回调、回调对象带不带状态、状态一共有几种。JUnit 5 把这件事分在两层:Jupiter 的扩展接口面向单个测试方法,Platform 的 Launcher 监听器面向整棵测试树。两层看到的原料不同,能支撑的统计口径也就不同。

一、直觉做法为什么会骗人

最常见的写法是在测试方法里套一层 try-catch:捕获到异常就记失败,没捕获到就记通过。它把”结局”和”方法体有没有执行”绑死了,而至少有四种情况根本不走这条路径。

  • 被 @Disabled 标注的用例:方法体一行都不跑,自己的计数器停在 0,与”全部通过”看起来完全一样。
  • 前置环节失败:@BeforeAll、参数供应器、依赖注入在测试方法开始之前就抛了,异常归到容器节点,落不到单个测试的 catch 上。
  • 假设不成立:Assumptions 系列方法失败走的是中止路径,语义是”环境不满足”,不是”代码错了”。
  • 异步断言:等待远端回调的断言在工作线程里抛出,主线程的 catch 看不见,最终由框架汇总。

更要命的是自己写的 catch 会把断言失败一起吞掉:断言抛出的错误类型本身就是普通可捕获异常,一旦接住不再上抛,框架收到的信号就变成”这个方法正常返回了”。于是”没抛异常”同时代表”真通过”和”根本没判”两种意思,这个数字失去了可解释性。判定权应该交还给框架——让执行结果对象的状态说话。

二、Jupiter 层:TestWatcher 给了四个回调

TestWatcher 是专门”看着一个测试怎么结束”的扩展接口,通过 @ExtendWith 或 @RegisterExtension 注册。它提供的回调名是四个:testDisabled、testSuccessful、testAborted、testFailed。有两点要提前说明,避免按想象写代码。

其一,接口里没有名为 testSkipped 的回调。”跳过”在 Jupiter 语义里由 disabled 表达;而被标签排除、被命令行过滤掉的用例不会触发任何回调,因为它们连进入执行的资格都没有。这正是”跳过”与”未运行”必须分开统计的原因:前者框架知道、也通知了你,后者框架压根不提。

其二,testSuccessful 是较新版本才补上的回调,@since 标注与方法参数形态请以所依赖版本的 javadoc 为准——扩展接口的参数在版本间调整过(早期形态会额外传测试信息对象)。如果项目用的版本没有成功回调,”通过”就得靠自己做起点与终点的对账:在扩展的前置回调里把这个测试的 id 登记为”已开始”,在任何终态回调里改写为终态,收尾时检查还有多少 id 停在”已开始”。这套对账逻辑并不复杂,但它给出的才是”通过”的完整定义:确实被调度、确实跑完、且没有任何失败或中止信号。

还有一个容易踩的实现细节:带状态的扩展,实例生命周期通常跟着测试实例走。默认的 per-method 测试实例策略下,实例字段在每个方法执行前都会被重新初始化,用实例字段累加会让数字不断归零。跨用例统计要么交给外部持有的并发安全聚合器,要么用与执行会话绑定的存储,要么显式把测试实例策略改成 per-class。开了并行执行之后,非线程安全的累加会给出比真实值更小的数——这种错最隐蔽,因为它只会让报表更好看。

三、Launcher 层:executionFinished 才是结局的权威入口

平台层提供 TestExecutionListener,常见回调名包括 testPlanExecutionStarted、testPlanExecutionFinished、dynamicTestRegistered、executionStarted、executionFinished、executionSkipped。它的注册有两条路:显式加到 LauncherConfig 的监听器列表里,或者用服务描述文件让平台自动发现(自动注册可被关闭,CI 里改动过启动器的项目要确认这一点)。

关键在于 executionFinished 的参数:除节点标识外,还会拿到一个执行结果对象,它能读出状态,而这个状态只有三种取值——成功、失败、中止;结果对象还携带异常信息,可用于把失败原因写进自定义报表。节点标识本身能区分容器与叶子,这一点决定了统计的正确性:容器节点也会有自己的结局事件,如果不加区分地累加,容器那一条会被当成一个额外用例计进去,总数虚高,而且参数组越多虚得越厉害。

executionSkipped 只在禁用或前置跳过时触发,携带一个跳过原因;这个原因参数在不同版本里的形态不完全一致,注册前请按所用版本核对签名,不要照抄别处的代码。而”被过滤掉”既不会触发 executionStarted,也不会触发 executionSkipped——它什么都没有。想抓它,只能从计划侧着手:测试计划在解析阶段就给出了完整的节点树,把树里的叶子 id 集合当作应跑基线,与结束时的实际结局集合做差集,差集就是”未运行”。

四、参数化与重复测试:父级怎么聚合子级结果

@ParameterizedTest 与 @RepeatedTest 在测试树里是容器,每一次调用是它下面的叶子节点,动态注册的用例还会额外触发 dynamicTestRegistered。聚合规则可以这样理解:

统计目标 容器节点够不够用 需要补的动作
一条参数化用例跑了几次 不够 数它下面的叶子节点,容器自身只给”整组有没有失败”
哪一个参数组合失败 不够 用叶子的事件与可读名称记录参数,容器结果不带定位信息
整组是否通过 够 只要有一个叶子失败,容器结局就是失败,二者不会互相抵消
父级成功但子级一个都没生成 危险 参数供应器返回空集时,父级结局在不同版本表现有差异,必须自己断言子节点数下限

最后这一行是真实事故源:供应参数的方法因为过滤条件写错返回了空流,测试组的容器可能依旧给出一个不难看的结局,而覆盖率实际为零。验收方式很朴素——对每组参数化用例断言”叶子数 >= 预期数”,把预期数写成常量放进代码评审。

同一套逻辑对 @TestFactory 生成的动态测试同样成立:动态用例的 id 每次运行都可能变,用 id 做趋势统计前先确认 key 的稳定性,否则历史数据对不上。

五、报表口径差异:绿了但没跑的六种来源

框架给出的状态是一回事,构建工具与 CI 汇总出来的数字是另一回事。以下几种错位在实践中出现得最多。

症状 真实原因 怎么验证
测试任务秒结束,日志显示 up-to-date 增量判定认为输入未变,整个测试任务没执行 强制重跑一次,比对用例总数变化
只跑了一个子集但流水线是绿的 命令行按名字或模式过滤,未匹配的类静默消失 打印实际执行数与类清单基线对账
某类用例从此再没出现过 标签或分组被排除列表命中 在流水线里同时输出 include 与 exclude 两组条件
迁移后老用例集体失踪 平台找不到对应版本的执行引擎,旧风格用例不被发现也不报错 加回引擎依赖,比对同一模块的用例总数
首跑失败却计入通过 失败重试后成功,报表只留最终结局 单独统计重试次数与首跑失败,把重试通过率当缺陷看
跳过被合并进通过 报表列只有三档,中止与禁用落到跳过,汇总脚本再把它并进绿色 按五态分别输出,禁止在脚本里做加和合并

还有一类与退出码有关:让测试失败不影响构建结果的开关一旦留在流水线里(无论是写进构建配置还是临时加在命令行),框架判定的失败就到此为止,CI 只看到一个成功的进程退出码。排查顺序建议是:先看有没有执行到框架的结局判定,再看执行结果有没有被工具层降级为警告。

六、最小落地:统一五态,把”未运行”当失败拦(洹拓视角)

不必改造测试代码,先把统计层收敛成五态:通过、失败、中止、跳过、未运行。可执行的五条:

  1. 用平台层监听器取终态,不用 Jupiter 扩展取成功——扩展的可用回调受版本影响,而结果对象的三态是稳定的判定入口。
  2. 每次运行落两份产物:一份”计划里的应跑叶子清单”,一份”结局表”,两者差集写入未运行计数,作为一等公民字段展示,不并进跳过。
  3. 断言不是”通过数 >= 阈值”,而是”应跑数 = 通过 + 失败 + 中止 + 跳过 + 未运行”且失败与未运行为零;等式不成立说明统计本身有 bug,同样拦构建。
  4. CI 里禁止重试后覆盖首跑结果,把每次尝试都留档;重试后通过只影响流水线颜色,不影响不稳定清单。
  5. 容器与叶子分开计数,报表上任何”用例数”都注明统计粒度,避免参数化用例被重复计算。
public final class OutcomeListener implements TestExecutionListener {

private final Map<String, Integer> counts = new ConcurrentHashMap<>();

@Override
public void executionFinished(TestIdentifier id, TestExecutionResult result) {
if (id.isTest()) {
counts.merge(result.getStatus().name(), 1, Integer::sum);
}
}

@Override
public void testPlanExecutionFinished(TestPlan plan) {
// 应跑叶子清单来自 plan,与 counts 做差集得到 not-run,再交给 CI 判定
OutcomeReport.write(counts, plan);
}
}

洹拓科技在工业项目的测试线上吃过一次反向的亏:报表一直是绿的,直到某次事故复盘才发现,一条本该拦住边界空指针的用例因为目录改名再没被执行过。之后我们把”可解释”作为质量门禁的硬要求——任何指标必须能说出它由哪个回调产生、缺了哪一档会被并进哪一类。测试的结局不是布尔值,是五种状态;把第五种拦住,前面四种才有意义。