测试报告的出口:JUnit XML 与判定引擎
一轮测试跑完——几百条用例、几万个仿真 tick——接下来有两个问题等着:结果以什么格式出门,以及”过还是不过”是谁判的。这篇把出口处的两块拼起来讲:前一半讲报告格式——JUnit XML 没有规范、没有 schema,却锁死了整个生态;后一半讲判定引擎——一个只读不写的”裁判”,每 tick 看一眼信号快照,把期望逐条推进状态机。
两者在末尾会师:判定引擎的汇总输出,正是 JUnit XML 里的那些 <testcase>。
一、JUnit XML 根本不是”标准”
JUnit XML 没有规范、没有 schema、没有任何组织拥有它。它只是 2000 年代初 Apache Ant 的 <junit> 任务吐出来的 TEST-*.xml 文件格式。后来 Hudson/Jenkins 原生解析它画趋势图——全世界的工具发现:只要我也吐这种 XML,就白捡 Jenkins 的报告 UI、趋势曲线、失败邮件。格式简单到任何语言一天就能写出一个 emitter。
二、正反馈网络效应(封神的真正机制)
CI 服务器支持它(Jenkins/GitLab CI/Azure DevOps/CircleCI)
↑↓
测试框架输出它(pytest --junitxml、gtest --gtest_output=xml、Catch2 -r junit……)
↑↓
下游工具消费它(报告聚合、ALM 回传、flake 检测)
三边互相加强形成护城河:换掉它要全工具链一起换,没有任何单点有动力先动。这就是事实标准和正式标准(比如 ASAM XIL)的本质差别——XIL 是委员会设计出来推给大家用,JUnit XML 是大家用出来了才成为”标准”。
三、格式设计刚好踩在甜点上
活下来不纯靠运气,结构确实”刚好够用”:
- 层级
testsuite > testcase:映射几乎所有测试框架的心智模型; - 四态语义 pass / fail / error / skipped:区分”断言失败”、”环境出错”和”跳过”——fail 是”产品错了”,skip 是”条件不具备”,两者对 CI 统计含义完全不同。JUnit XML 原生支持这个区分,这是它比纯文本日志强的地方;
- 官方口袋
<properties>/<system-out>/<system-err>:随便塞自定义字段——需求 ID、ASIL 等级、artifact 链接全塞这里。ALM 回传报文里每个<testcase>都必须带需求 ID,靠的就是 properties 这个口袋; - 纯文本 XML:人可读、diff 友好、不依赖任何运行时。
四、为什么汽车行业也吃它
- 汽车测试报告层没有强势行业标准(ASAM 有 ATX 管用例交换,但报告层是真空),JUnit XML 顺手填坑;
- ECU-TEST、CANoe、VectorCAST 全都能导出 JUnit → 进 Jenkins → 回传 ALM;
- 但注意局限:它表达不了信号曲线、总线 trace、视频证据——汽车测试报告的富数据只能靠附件链接塞在 properties / system-out 里。所以商用工具都拿它当”骨架”,富媒体挂外面。这个局限决定了它将来最可能被增强而非取代。
五、局限与继任者
- 各家方言不一致(suite 嵌套语义、时间戳格式、附件支持都乱)——没有 schema 权威的代价;
- 继任者在冒头:CTRF(Common Test Report Format,JSON)、Allure 格式——但网络效应的惯性下,JUnit XML 的王座短期不动摇。
报告格式定了,接下来的问题是:写进报告的那个”过/不过”,是谁判出来的?
六、判定引擎站在哪:数据通路的最末端
一个典型的判定引擎(verdict engine)设计是这样的:它只读不写,每 tick 看一眼信号世界的快照,把声明的期望逐条推进状态机。拆开来讲:
总线帧 → 解码(FlatBuffers) → SignalStore(信号快照) ──▶ VerdictEngine.evaluate(tick)
▲ ▲
FaultPipe → FaultEvent 计数 ──────┘ YAML 断言规则
两个输入源:
- SignalStore:每 tick 由解码器更新的信号当前值表(
brake_request = true、vehicle_speed = 42.5……),本质是一张SignalId → double的平铺数组; - 故障计数器:故障注入模块的 FaultEvent 统计,支撑
fault_injected(...)这类断言。
它自己是纯消费者:不改任何信号,不发任何帧。这个”只读”约束很重要——裁判永远不能碰比赛。
七、核心设计:每条断言 = 一台小状态机
判定引擎最容易写错的方式是”跑完测试再扫日志判一次”。稳妥的做法是逐 tick 增量求值——每条断言在仿真开始前就实例化好,每 tick 推进一步:
// 条件:断言的"本体",variant 封闭集合
struct EqSignal { SignalId sig; double expected; };
struct InRange { SignalId sig; double lo, hi; };
struct SeqAfter { SignalId trig_sig; double trig_val; // A 发生后
SignalId cons_sig; double cons_val; // B 必须在 T 内发生
uint32_t window_ticks; };
struct FaultHits { uint32_t rule_id; uint32_t min_hits; };
using Condition = std::variant<EqSignal, InRange, SeqAfter, FaultHits>;
// 断言:条件 + 时间窗 + 状态
struct Assertion {
const char* name; // 字符串池
Condition cond;
Tick window_start; // 从这一刻开始监听
Tick deadline; // "by 2500ms" → 截止 tick
State state = State::Pending; // Pending/Satisfied/Violated/TimedOut
// 失败现场快照(报告用)
double last_actual = 0;
Tick last_change = 0;
};
class VerdictEngine {
std::array<Assertion, kMaxAssertions> assertions_{};
size_t count_ = 0;
public:
void evaluate(Tick now, const SignalStore& sig, const FaultStats& faults);
CaseResult summarize() const; // 汇总给 JUnit 报告
};
状态机转移规则(以 expect: brake_request == true, by: 2500ms 为例):
Pending ──条件为真──────────────▶ Satisfied(锁存,之后不再判)
│
└── tick 越过 deadline 仍为假 ──▶ TimedOut(记录现场:last_actual=0, 最后变化时刻)
always 类断言反过来:窗口内一旦为假立即 Violated 锁存。所有终态都锁存——一条断言一生只判一次结果,这保证了同一日志重放必然得到同一判定。
八、每 tick 的求值过程
void VerdictEngine::evaluate(Tick now, const SignalStore& sig, const FaultStats& fs) {
for (size_t i = 0; i < count_; ++i) {
auto& a = assertions_[i];
if (a.state != State::Pending) continue; // 终态跳过
if (now < a.window_start) continue; // 还没进监听窗
bool ok = std::visit([&](const auto& c){ return check(c, sig, fs, a); }, a.cond);
if (ok) {
a.state = State::Satisfied;
} else if (now >= a.deadline) {
a.state = State::TimedOut;
a.last_actual = readActual(a.cond, sig); // 抓现场
a.last_change = sig.lastChangeTick(signalOf(a.cond));
}
}
}
注意三个纪律的体现:
- 零分配:断言数组定长、名称在字符串池、
std::visit不开虚函数; - 纯函数性:
evaluate的输出只取决于(now, SignalStore, FaultStats),不碰墙钟——bitwise 可复现的又一块基石; - 复杂度:每 tick O(断言数),几百条断言在 1kHz 节拍下的开销可以忽略。
九、YAML 到状态机的映射
verdict:
- expect: brake_request == true # → EqSignal
by: 2500ms # → deadline = 2500 ticks
- expect: vehicle_speed in [0, 5] # → InRange
by: 4000ms
- expect: fault_injected(radar_dropout, min_hits: 200) # → FaultHits
by: 1500ms
- expect: target_valid == false then dtc_reported == true within 500ms
# → SeqAfter 两段式状态机
加载期(允许堆分配的那个阶段)把每条 expect 编译成一个 Assertion:解析信号名→SignalId、时间字符串→tick 数、动作名→rule_id。编译期能做的检查全在加载期做掉(信号不存在、时间窗倒挂、rule_id 未定义),RT 循环里只剩整数比较。
SeqAfter 值得单独看一眼——它是两段状态机:
WaitTrigger ──trig 条件为真──▶ Watching(记录 trig_tick)
│ cons 在 trig+T 内为真 ──▶ Satisfied
└── 超过 trig+T 仍为假 ────▶ TimedOut
有了它,”故障发生后 SUT 必须在 500ms 内报 DTC”这类时序因果断言就能声明了——这是功能安全验证里最常写的句式。
十、失败时抓什么:判定要有”现场”
TimedOut 只写”期望 brake_request==true,超时”的报告没用。锁存失败时一并抓:
| 现场信息 | 来源 | 报告里长什么样 |
|---|---|---|
| 信号最后的实际值 | SignalStore | brake_request 始终为 0 |
| 信号最后一次变化时刻 | SignalStore 的 change-tick | t=380ms 后再未变化 |
| 该 tick 的故障注入状态 | FaultStats | 此时 radar_dropout 生效中 |
| 相关信号最近 N 个值 | 可选的环形历史 | 附在 failure 详情里 |
十一、汇总输出:到 JUnit 的映射
CaseResult VerdictEngine::summarize() const {
// 每条断言 → JUnit 的一个 <testcase>,或整案一个 <testcase>、
// 失败断言 → <failure message="...">,消息用 fmt 拼现场快照
// fault 命中统计 → <properties>
}
报告里一条失败大概长这样:
FAILED: aeb_radar_dropout / brake_request == true by 2500ms
actual: 0 (自 t=380ms 起未变化)
context: radar_dropout 生效中 [1000ms,1300ms), 已注入 240 次
审阅报告的人不用翻总线日志就能定位问题方向——是 SUT 没决策,还是帧没发出来。
十二、一个免费的能力:离线复判
因为引擎只吃 SignalStore + FaultStats,而这两者都能从总线日志重建,所以同一个引擎可以直接对录制日志离线重判:改了断言阈值不用重跑仿真,喂日志就行。这也是”执行面/证据面分离”架构的红利——判定逻辑和激励执行彻底解耦。
十三、落地优先级
| 阶段 | 内容 |
|---|---|
| P0 | EqSignal + by 截止 + JUnit 输出(覆盖 80% 用例) |
| P1 | InRange、always 窗口断言、失败现场快照 |
| P2 | FaultHits(接故障注入证据链)、SeqAfter 时序断言 |
| P3 | 离线复判模式、信号历史环形缓冲 |
一句话总结:判定引擎 = 一组在加载期编译好的断言状态机 + 每 tick 一次的纯函数求值 + 失败现场锁存 + JUnit 映射。它不追求聪明,追求的是每一次判定都确定、可复现、可审计——这才是安全证据链末端该有的样子。
回到出口的两半:JUnit XML 回答”结果以什么格式出门”——它靠网络效应封神,也靠刚好够用的设计坐稳;判定引擎回答”结果是谁判的”——它只读、逐 tick、锁存终态,把每一次判定做成可复现、可审计的证据。一个是生态问题,一个是设计问题,但两者在 <testcase> 里会师:报告里每一行 pass/fail,都是一台状态机走到终态后留下的脚印。
附:名词解释(按出场顺序)
| 名词 | 通俗解释 |
|---|---|
| JUnit XML | 测试结果的通用上报格式:源于 Ant 的 <junit> 任务,无规范却成了事实标准 |
| emitter | 产生并写出某种格式文件的代码模块;这里指吐 JUnit XML 的报告输出器 |
| CI | 持续集成:每次提交自动构建并跑测试的流水线 |
| ALM | 应用生命周期管理工具链,需求—用例—结果的追溯归它管 |
| flake | 不改代码、时而过时不过的不稳定用例 |
| ASAM XIL | 汽车仿真测试的正式标准:定义测试脚本访问台架的通用接口,委员会制定 |
| 四态(pass/fail/error/skipped) | JUnit XML 的用例结果分类:断言失败 / 环境出错 / 条件不具备而跳过 |
<properties> |
JUnit XML 里放自定义键值对的官方口袋,需求 ID、ASIL 等级都塞这里 |
| ASIL | 汽车安全完整性等级(QM/A/B/C/D,D 最严),ISO 26262 的核心概念 |
| ASAM ATX | 供应链之间交换测试用例描述的标准格式 |
| 总线 trace | 总线上所有帧的带时间戳完整记录 |
| CTRF | Common Test Report Format:基于 JSON 的新一代通用测试报告格式 |
| 判定引擎(verdict engine) | 测试系统里的”裁判”:只读信号快照,把期望逐条推进状态机得出过/不过 |
| tick | 仿真推进的最小时间步;1kHz 节拍下 1 tick = 1ms |
| SignalStore | 每 tick 更新的信号当前值表,本质是一张 SignalId → double 的平铺数组 |
| FaultEvent | 故障注入模块的事件计数,支撑 fault_injected(...) 这类断言 |
| variant 封闭集合 | C++ 的 std::variant:类型在编译期列全,访问不开虚函数 |
| 锁存(终态) | 状态机一旦进入终态就不再变——一条断言一生只判一次结果 |
| 零分配 | 实时循环里不做任何堆内存分配的纪律,靠定长数组和字符串池实现 |
| bitwise 可复现 | 同一输入重跑,输出逐比特一致;不碰墙钟是其前提之一 |
| SeqAfter | 两段式时序断言:”A 发生后,B 必须在 T 内发生” |
| SUT | 被测系统(System Under Test) |
| DTC | 诊断故障码:控制器检测到故障后记录并上报的标准化代码 |
| 失败现场快照 | 锁存失败时一并抓下的上下文:最后实际值、最后变化时刻、故障状态等 |
| 离线复判 | 用同一个判定引擎对录制日志重新判定,改阈值不用重跑仿真 |
下一步:
查看全部文章