测试报告的出口: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 是大家用出来了才成为”标准”。

三、格式设计刚好踩在甜点上

活下来不纯靠运气,结构确实”刚好够用”:

四、为什么汽车行业也吃它

五、局限与继任者

报告格式定了,接下来的问题是:写进报告的那个”过/不过”,是谁判出来的?

六、判定引擎站在哪:数据通路的最末端

一个典型的判定引擎(verdict engine)设计是这样的:它只读不写,每 tick 看一眼信号世界的快照,把声明的期望逐条推进状态机。拆开来讲:

总线帧 → 解码(FlatBuffers) → SignalStore(信号快照) ──▶ VerdictEngine.evaluate(tick)
                                  ▲                          ▲
FaultPipe → FaultEvent 计数 ──────┘                    YAML 断言规则

两个输入源:

它自己是纯消费者:不改任何信号,不发任何帧。这个”只读”约束很重要——裁判永远不能碰比赛。

七、核心设计:每条断言 = 一台小状态机

判定引擎最容易写错的方式是”跑完测试再扫日志判一次”。稳妥的做法是逐 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));
        }
    }
}

注意三个纪律的体现:

九、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 诊断故障码:控制器检测到故障后记录并上报的标准化代码
失败现场快照 锁存失败时一并抓下的上下文:最后实际值、最后变化时刻、故障状态等
离线复判 用同一个判定引擎对录制日志重新判定,改阈值不用重跑仿真

下一步:

查看全部文章