Parses JUnit-format XML reports (the de-facto interchange format every CI ingests - Jenkins, GitHub Actions, GitLab, Buildkite, CircleCI) into structured, machine-readable per-suite and per-case metrics tables (passed / failed / errored / skipped, time, classname, message, stack), groups failures by classname for trend analysis, and distinguishes "new failures vs flakes" by cross-referencing the `flakyFailure` and `rerunFailure` rerun elements. Use when the downstream consumer is a dashboard, script, or aggregator - not when the goal is a human-readable prose summary (use test-run-summary-author for that). Single-run, in-XML aggregation only; for cross-run cross-environment roll-ups, use a cross-run test-suite aggregator.
74
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
The "JUnit XML" format is the de-facto schema every CI consumes, emitted by virtually every test runner (pytest, Jest, Vitest, Go test, Maven Surefire, Cypress, Playwright, and the rest).
Per llg-junit (the community schema reference used by Jenkins's parser):
"Root element:
<testsuites>(optional if only one suite exists;<testsuite>can be the root instead)."
The hierarchy is testsuites → testsuite → testcase, with
result child elements (<failure>, <error>, <skipped>) hanging
off each testcase. This skill covers parsing the format, building
per-suite + per-case metrics, and the flaky-vs-new distinction via
the modern <rerunFailure> / <flakyFailure> extensions.
Per llg-junit:
| Level | Required attributes | Common attributes |
|---|---|---|
testsuites | (none required at root) | tests, failures, errors, disabled, time, name |
testsuite | name, tests | failures, errors, skipped, time, timestamp, hostname, id, package |
testcase | name, classname | time, assertions, status |
Each <testcase> contains at most one of:
<skipped message=""> - test not executed<error message="" type=""> - "unanticipated problem (uncaught
exception, crash)" (llg-junit)<failure message="" type=""> - "explicit test failure
(assertion failed)" (llg-junit)Plus optional:
<system-out> - stdout captured during execution<system-err> - stderr captured during execution<properties> - environment settings as name/value pairsCritical distinction: per llg-junit, <failure> is an
assertion failure (the test made a claim that came back false).
<error> is an exception or crash before the assertion ran. Group
them differently in dashboards - errors are usually environment /
infra; failures are usually code or fixture drift.
Use a streaming parser for large files (multi-thousand-test suites are common). Python core:
# scripts/parse_junit.py
import xml.etree.ElementTree as ET
def parse_junit(path):
tree = ET.parse(path)
root = tree.getroot()
suites = root.findall('testsuite') if root.tag == 'testsuites' else [root]
for suite in suites:
for case in suite.findall('testcase'):
fault = case.find('failure')
if fault is None:
fault = case.find('error')
yield {
'suite': suite.get('name'),
'classname': case.get('classname'),
'name': case.get('name'),
'time': float(case.get('time') or 0),
'status': classify(case),
'failure_message': fault.get('message') if fault is not None else None,
}
def classify(case):
if case.find('failure') is not None: return 'failure'
if case.find('error') is not None: return 'error'
if case.find('skipped') is not None: return 'skipped'
return 'pass'An Element with no children is falsy, so case.find('failure') or case.find('error') would skip a childless <failure>; test the nodes
with is None instead.
Always handle both root shapes: the root may be <testsuites> or a
bare <testsuite>. The Node.js (fast-xml-parser) equivalent, which
also has to undo single-element collapsing (one testcase = bare object,
multiple = array), is in
references/junit-xml-parsing.md.
Per llg-junit, the schema "supports modern variants
including <flakyFailure>, <flakyError>, <rerunFailure>, and
<rerunError> elements for additional test run metadata."
When the runner does automatic retries (Maven Surefire's
rerunFailingTestsCount, pytest-rerunfailures, etc.):
<flakyFailure> (or
<flakyError>) child with the original failure.<rerunFailure> children plus the final <failure>.Classification:
def reliability(case):
has_flaky = case.find('flakyFailure') is not None or case.find('flakyError') is not None
has_rerun = case.find('rerunFailure') is not None or case.find('rerunError') is not None
has_final = case.find('failure') is not None or case.find('error') is not None
if has_flaky and not has_final: return 'flaky' # passed on retry
if has_rerun and has_final: return 'consistently_failing'
if has_final: return 'newly_failed'
return 'pass'Surface flaky tests in a separate report - they're noise to the PR author but signal to the test-suite owner.
from collections import defaultdict
def per_suite(cases):
agg = defaultdict(lambda: {'pass': 0, 'failure': 0, 'error': 0, 'skipped': 0, 'flaky': 0, 'time': 0.0})
for c in cases:
agg[c['suite']][c['status']] += 1
agg[c['suite']]['time'] += c['time']
return aggTo detect "is this a new failure or has this test been failing for a week?", store every run's parsed metrics in a per-suite history file:
{"sha":"abc123","ts":"2026-05-05T14:00:00Z","suite":"checkout","failure":2,"flaky":1,"time":12.4}
{"sha":"def456","ts":"2026-05-05T14:30:00Z","suite":"checkout","failure":2,"flaky":0,"time":12.1}Compare by suite + classname:
| classname | name | last 5 runs result | first failed sha |
|---|---|---|---|
cart.CartTest | addItem_validatesStock | F F F F F | abc123 (5 days ago) |
checkout.PromoTest | applyPromo_caseInsensitive | P P P P F | this PR (suspected regression) |
The first row is a stale failure; the second is a probable regression.
Sort testcases by time descending. The top 1% is the fast
feedback target - moving any one of them from 30s → 3s saves more
than refactoring a hundred tests that already run in <100ms.
Run tests with a JUnit reporter enabled, then parse and upload the
results on if: always() - JUnit XML matters most on failed runs, so a
step gated on success would drop exactly the data you need. The full
GitHub Actions workflow (reporter env var, parse step, artifact upload)
is in
references/junit-xml-parsing.md.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Treating <error> and <failure> as the same | Errors are usually infra (DB connection lost), failures are usually code. Conflating hides root-cause patterns. | Group them separately. |
Dropping <flakyFailure> reports from the dashboard | Hidden flake budget; quality erodes silently. | Surface flaky tests on a separate panel; assign owner. |
Loading multi-MB XML with xml.dom.minidom.parseString | Whole-tree-in-memory. OOM on large suites. | xml.etree.ElementTree.iterparse for streaming. |
Failing the build on any <skipped> count > 0 | Many runners legitimately skip (platform-gated, conditional). | Skip is informational; only fail on failure / error. |
Hardcoding <testsuites> as the root | Some runners emit a single <testsuite> as the root. | Detect both shapes (Step 2). |
Trusting time for sub-millisecond tests | Some runners emit 0 for any test under their granularity; sort breaks. | Treat time = 0 as "not measured"; don't include in slow-test list. |
Cross-suite aggregation by name alone | Two suites can have a it('renders') each - merging false-flags both. | Always group by (classname, name) tuple. |
assertions
may or may not appear; the parser must be tolerant.<failure>
element's body is unstructured text - assertion targets,
expected vs actual, and source line are runner-dependent.<flakyFailure> - flake detection
has to come from cross-run comparison instead (Step 5).<flakyFailure> / <rerunFailure>.coverage-diff-reporter -
parallel skill for coverage report diffs (different format,
same PR-time analytics shape).allure-reports - richer reporting
built on top of allure-results; consumes JUnit XML via per-runner
adapters when needed.