How to Parse JUnit XML Test Reports (Complete Guide)
JUnit XML is the universal language of test results. Whether you're running Jest, pytest, Go tests, or Playwright end-to-end suites, chances are your test framework can output results in JUnit XML format.
But what exactly is inside a JUnit XML report? How is it structured, and how do you parse it programmatically? This guide walks through the complete JUnit XML schema, shows you how to parse reports in Node.js and Python, and covers the gotchas that trip up most developers.
What Is JUnit XML?
JUnit XML is a de facto standard for representing test report results in XML. It was originally created by the JUnit testing framework for Java but has since been adopted across virtually every programming language and test framework.
The format encodes:
- Test suites and their aggregate statistics
- Individual test cases with pass, fail, error, or skipped status
- Failure messages and stack traces
- Timing information for each test and suite
- Standard output and error captured during test execution
CI/CD platforms, test dashboards, and reporting tools all understand JUnit XML, making it the lingua franca of test results.
The JUnit XML Schema
A JUnit XML report has a hierarchical structure with three main elements: <testsuites>, <testsuite>, and <testcase>.
The Root: <testsuites>
The top-level element wraps one or more test suites:
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="All Tests" tests="42" failures="2" errors="1" skipped="3" time="12.345">
<testsuite>...</testsuite>
<testsuite>...</testsuite>
</testsuites>Key attributes:
tests— total number of test casesfailures— tests that ran but produced unexpected resultserrors— tests that crashed or threw exceptionsskipped— tests that were intentionally not runtime— total execution time in seconds
Some frameworks emit a single <testsuite> as the root element instead of wrapping it in <testsuites>. Your parser should handle both cases.
Test Suites: <testsuite>
Each <testsuite> groups related test cases, typically mapping to a test file or describe block:
<testsuite name="UserService" tests="8" failures="1" errors="0" skipped="0"
time="2.456" timestamp="2026-03-20T10:30:00" file="tests/user-service.test.ts">
<testcase>...</testcase>
<testcase>...</testcase>
<properties>
<property name="framework" value="vitest" />
</properties>
<system-out>Console output captured during suite execution</system-out>
<system-err>Error output captured during suite execution</system-err>
</testsuite>The <properties> element carries arbitrary key-value metadata. The <system-out> and <system-err> elements capture console output.
Test Cases: <testcase>
Individual tests are represented by <testcase> elements:
<!-- Passing test -->
<testcase name="should create a user" classname="UserService" time="0.042" />
<!-- Failing test -->
<testcase name="should validate email" classname="UserService" time="0.018">
<failure message="Expected valid email" type="AssertionError">
AssertionError: Expected valid email
at UserService.test.ts:42
at Object.runTest (runner.js:108)
</failure>
</testcase>
<!-- Error (crash) -->
<testcase name="should connect to database" classname="UserService" time="0.001">
<error message="Connection refused" type="Error">
Error: Connection refused
at DatabaseClient.connect (db.ts:15)
</error>
</testcase>
<!-- Skipped test -->
<testcase name="should handle edge case" classname="UserService" time="0">
<skipped message="TODO: implement edge case handling" />
</testcase>The key distinction between <failure> and <error>:
<failure>means the test ran but an assertion didn't hold — the code under test produced wrong results.<error>means the test crashed — an unexpected exception prevented it from completing.<skipped>means the test was intentionally excluded from execution.
A <testcase> with none of these child elements is a passing test.
Parsing JUnit XML in Node.js
Here's how to parse JUnit XML reports in Node.js using fast-xml-parser:
import { XMLParser } from "fast-xml-parser";
import { readFileSync } from "node:fs";
interface TestCase {
name: string;
classname?: string;
time: number;
status: "passed" | "failed" | "error" | "skipped";
message?: string;
}
function parseJunitXml(filePath: string): TestCase[] {
const xml = readFileSync(filePath, "utf-8");
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
});
const parsed = parser.parse(xml);
// Handle both <testsuites> and standalone <testsuite> root
const suites = parsed.testsuites?.testsuite ?? parsed.testsuite;
const suiteArray = Array.isArray(suites) ? suites : [suites];
const results: TestCase[] = [];
for (const suite of suiteArray) {
if (!suite?.testcase) continue;
const cases = Array.isArray(suite.testcase)
? suite.testcase
: [suite.testcase];
for (const tc of cases) {
let status: TestCase["status"] = "passed";
let message: string | undefined;
if (tc.failure) {
status = "failed";
message = tc.failure["@_message"] ?? String(tc.failure);
} else if (tc.error) {
status = "error";
message = tc.error["@_message"] ?? String(tc.error);
} else if (tc.skipped !== undefined) {
status = "skipped";
message = tc.skipped?.["@_message"];
}
results.push({
name: tc["@_name"],
classname: tc["@_classname"],
time: parseFloat(tc["@_time"] || "0"),
status,
message,
});
}
}
return results;
}Handling Single vs. Array Elements
The biggest gotcha in XML parsing: when there's only one <testcase>, many parsers return an object instead of an array. Always normalize:
const cases = Array.isArray(suite.testcase)
? suite.testcase
: [suite.testcase];This pattern applies to <testsuite> elements too. A report with a single suite and a report with multiple suites should both work.
Parsing JUnit XML in Python
Python's built-in xml.etree.ElementTree handles JUnit XML efficiently:
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import Optional
@dataclass
class TestResult:
name: str
classname: Optional[str]
time: float
status: str # "passed", "failed", "error", "skipped"
message: Optional[str] = None
def parse_junit_xml(file_path: str) -> list[TestResult]:
tree = ET.parse(file_path)
root = tree.getroot()
# Handle both <testsuites> and <testsuite> as root
if root.tag == "testsuites":
suites = root.findall("testsuite")
else:
suites = [root]
results = []
for suite in suites:
for tc in suite.findall("testcase"):
name = tc.get("name", "unknown")
classname = tc.get("classname")
time = float(tc.get("time", "0"))
failure = tc.find("failure")
error = tc.find("error")
skipped = tc.find("skipped")
if failure is not None:
status = "failed"
message = failure.get("message", failure.text)
elif error is not None:
status = "error"
message = error.get("message", error.text)
elif skipped is not None:
status = "skipped"
message = skipped.get("message")
else:
status = "passed"
message = None
results.append(TestResult(
name=name,
classname=classname,
time=time,
status=status,
message=message,
))
return resultsCommon Gotchas
1. Character Encoding Issues
JUnit XML files should declare UTF-8 encoding, but some frameworks produce files with different encodings or invalid XML characters. Control characters (bytes 0x00–0x08, 0x0B, 0x0C, 0x0E–0x1F) are invalid in XML and will crash most parsers.
Fix: Strip invalid XML characters before parsing:
function sanitizeXml(xml: string): string {
// Remove characters invalid in XML 1.0
return xml.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
}2. Nested Test Suites
Some frameworks (particularly Java-based ones) produce nested <testsuite> elements. Your parser needs to recurse:
<testsuites>
<testsuite name="All Tests">
<testsuite name="Unit Tests">
<testcase name="test_one" />
</testsuite>
<testsuite name="Integration Tests">
<testcase name="test_two" />
</testsuite>
</testsuite>
</testsuites>3. Missing Attributes
Not every framework populates every attribute. The time attribute might be missing, classname might be empty, and tests/failures counts on <testsuite> might not match the actual child elements.
Always use defaults:
const time = parseFloat(tc["@_time"] || "0");
const name = tc["@_name"] || "unnamed test";4. Multiple Report Files
CI pipelines running parallel test jobs produce multiple JUnit XML files. You need to glob for and merge them:
import { globSync } from "node:fs";
const reportFiles = globSync("test-results/**/*.xml");
const allResults = reportFiles.flatMap((file) => parseJunitXml(file));Framework-Specific Output
Jest
Install jest-junit and configure in jest.config.js:
module.exports = {
reporters: [
"default",
["jest-junit", { outputDirectory: "test-results" }],
],
};pytest
Built-in support via the --junitxml flag:
pytest --junitxml=test-results/results.xmlGo
Use go-junit-report to convert Go test output:
go test -v ./... 2>&1 | go-junit-report > test-results/results.xmlVitest
Configure the JUnit reporter in vitest.config.ts:
export default defineConfig({
test: {
reporters: ["default", "junit"],
outputFile: { junit: "test-results/results.xml" },
},
});Playwright
Add the JUnit reporter in playwright.config.ts:
export default defineConfig({
reporter: [["junit", { outputFile: "test-results/results.xml" }]],
});RSpec (Ruby)
Add rspec_junit_formatter to your Gemfile:
gem "rspec_junit_formatter"rspec --format RspecJunitFormatter --out test-results/results.xmlUsing JUnit XML in CI/CD
Once your tests produce JUnit XML, you can feed the reports into CI tools for richer reporting:
GitHub Actions
Use a test reporter action to display results in your PR:
- name: Run tests
run: npm test
- name: Report test results
uses: testglance/action@v1
if: always()
with:
api-key: ${{ secrets.TESTGLANCE_API_KEY }}This sends your test reports to a dashboard where you can track test suite health over time, detect flaky tests, and catch regressions before they reach production.
FAQ
What is the difference between failure and error in JUnit XML?
A <failure> indicates an assertion failed — the test ran to completion but the expected outcome didn't match. An <error> indicates the test crashed with an unexpected exception before it could complete. Both mean the test didn't pass, but they represent different kinds of problems.
Can I use JUnit XML with non-Java test frameworks?
Absolutely. Despite the name, JUnit XML is a universal format. Frameworks in JavaScript, Python, Go, Ruby, PHP, Rust, and nearly every other language can produce JUnit XML output. It's the most widely supported test report format in the industry.
How do I merge multiple JUnit XML files?
When running tests in parallel, each shard produces its own report. You can merge them by parsing each file, combining the test case arrays, and optionally re-computing the aggregate counts. Tools like junit-report-merger (npm) or custom scripts using the parsing techniques shown above handle this efficiently.
Next Steps
Parsing JUnit XML is the first step toward understanding your test results programmatically. Once you can parse reports, you can build trend tracking, flaky test detection, and automated health scoring on top of your CI pipeline.
Start monitoring your test results with TestGlance to automatically parse, track, and analyze your JUnit XML reports across every CI run.