jest-junit: generate JUnit XML reports from Jest and surface them in CI
jest-junit transforms Jest's output into JUnit XML — the format every CI platform and test analytics tool understands. Here's how to configure it correctly.
BuildPulse Team
May 30, 2026
The problem: Jest results disappear after a failed build
Jest prints beautiful output to stdout. But once a CI run finishes — especially a failed one — that output is gone. You get a wall of red text in a log file, no historical trend, no way to tell whether a failure is a genuine regression or the same flaky test that's been burning your team for three weeks.
The fix is to emit results in JUnit XML format, the de facto standard that GitHub Actions, CircleCI, Datadog, BuildPulse, and every other CI/CD tool knows how to ingest. jest-junit is the reporter that does exactly that.
This post covers:
- Installing and configuring
jest-junit - The configuration options that actually matter
- Wiring it into a GitHub Actions workflow
- Common pitfalls and how to avoid them
Installing jest-junit
npm install --save-dev jest-junit
# or
yarn add --dev jest-junit
Then register it as a reporter in your Jest config. You can do this in jest.config.js, jest.config.ts, or directly in package.json.
// jest.config.js
module.exports = {
reporters: [
'default', // keep the normal stdout output
'jest-junit', // also emit JUnit XML
],
};
The 'default' entry is important. Without it, Jest drops its normal terminal reporter and you lose human-readable output in local development.
Configuration options that actually matter
jest-junit has a lot of knobs. Most of them you can ignore. These are the ones worth understanding.
Output path
By default, jest-junit writes to junit.xml in the current working directory. In a monorepo or a project with multiple Jest configs, every package will try to write to the same file and overwrite each other.
Set an explicit path:
// jest.config.js
module.exports = {
reporters: [
'default',
[
'jest-junit',
{
outputDirectory: './test-results',
outputName: 'jest-junit.xml',
},
],
],
};
Or use the JEST_JUNIT_OUTPUT_DIR and JEST_JUNIT_OUTPUT_NAME environment variables if you want to keep the Jest config clean and set paths in CI.
Suite and classname templates
JUnit XML has a hierarchy: testsuites → testsuite → testcase. How jest-junit maps Jest's structure to that hierarchy is controlled by suiteName, classname, and title.
The defaults work, but they often produce awkward names in test dashboards. A common setup:
[
'jest-junit',
{
outputDirectory: './test-results',
outputName: 'jest-junit.xml',
classname: '{classname}',
title: '{title}',
ancestorSeparator: ' › ',
},
]
ancestorSeparator controls how nested describe blocks are joined in the test name. The default is a space, which can make test names ambiguous. Using › (or ::) makes nesting obvious in dashboards.
Including file paths
When a test fails, you want to be able to jump straight to the file. Set addFileAttribute to include the source file in the XML:
[
'jest-junit',
{
outputDirectory: './test-results',
outputName: 'jest-junit.xml',
addFileAttribute: 'true',
},
]
This adds a file attribute to each testcase element. Tools that support it (BuildPulse, some GitHub Actions integrations) can link directly to the file.
Wiring it into GitHub Actions
Here's a complete workflow that runs Jest, always uploads the JUnit report, and uploads it to BuildPulse for flaky test detection.
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
jobs:
jest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npx jest --ci
env:
JEST_JUNIT_OUTPUT_DIR: ./test-results
JEST_JUNIT_OUTPUT_NAME: jest-junit.xml
- name: Upload test results to BuildPulse
if: '!cancelled()'
uses: buildpulse/buildpulse-action@main
with:
account: ${{ secrets.BUILDPULSE_ACCOUNT_ID }}
repository: ${{ secrets.BUILDPULSE_REPOSITORY_ID }}
path: ./test-results/jest-junit.xml
key: ${{ secrets.BUILDPULSE_ACCESS_KEY_ID }}
secret: ${{ secrets.BUILDPULSE_ACCESS_SECRET }}
- name: Upload test results artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: jest-junit-results
path: ./test-results/jest-junit.xml
retention-days: 14
Two things to pay attention to here:
--ci flag. When running in CI, always pass --ci to Jest. It disables interactive mode, fails if the snapshot file is outdated rather than writing a new one, and exits non-zero on test failures without the retry behavior that can mask problems.
if: '!cancelled()' vs if: always(). Use !cancelled() for steps that should run after a failure but not after a manual cancellation (like uploading results to an external service). Use always() for artifact uploads where you want the report even if the job is cancelled mid-run.
Handling parallel test runs
If you shard Jest across multiple runners using --shard, each shard writes its own XML file. You need to collect all of them before uploading.
jobs:
jest:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run tests (shard ${{ matrix.shard }}/4)
run: npx jest --ci --shard=${{ matrix.shard }}/4
env:
JEST_JUNIT_OUTPUT_DIR: ./test-results
JEST_JUNIT_OUTPUT_NAME: jest-results-${{ matrix.shard }}.xml
- name: Upload shard results
if: '!cancelled()'
uses: actions/upload-artifact@v4
with:
name: jest-results-shard-${{ matrix.shard }}
path: ./test-results/jest-results-${{ matrix.shard }}.xml
report:
needs: jest
runs-on: ubuntu-latest
if: '!cancelled()'
steps:
- name: Download all shard results
uses: actions/download-artifact@v4
with:
pattern: jest-results-shard-*
merge-multiple: true
path: ./test-results
- name: Upload to BuildPulse
uses: buildpulse/buildpulse-action@main
with:
account: ${{ secrets.BUILDPULSE_ACCOUNT_ID }}
repository: ${{ secrets.BUILDPULSE_REPOSITORY_ID }}
path: ./test-results
key: ${{ secrets.BUILDPULSE_ACCESS_KEY_ID }}
secret: ${{ secrets.BUILDPULSE_ACCESS_SECRET }}
Note that JEST_JUNIT_OUTPUT_NAME is templated with the shard index so each shard writes a distinct file. The report job then downloads all artifacts into the same directory and uploads the whole directory.
Common pitfalls
Missing testResultsProcessor vs reporters. Older jest-junit documentation (and many Stack Overflow answers) show using "testResultsProcessor": "jest-junit" in package.json. That API still works but is deprecated. Use reporters instead — it gives you more control and lets you keep the default reporter alongside jest-junit.
Empty XML on test suite error. If Jest itself throws before running any tests (a syntax error in your config, a missing module), jest-junit never gets called and no XML is written. Your upload step will fail with "file not found." Guard against this by creating an empty placeholder or by checking for the file's existence before uploading.
Timestamps in XML causing false flake signals. Some test analytics tools use timestamps in the JUnit XML to compute test duration trends. jest-junit includes wall-clock timestamps by default. If your CI machines have clock skew, this can produce noisy duration data. This usually isn't a problem in practice, but worth knowing if you see bizarre duration graphs.
Monorepo path collisions. In a monorepo where you run jest from multiple package directories, set JEST_JUNIT_OUTPUT_DIR to an absolute path (or a path relative to the repo root, not the package) and include the package name in JEST_JUNIT_OUTPUT_NAME. Otherwise reports silently overwrite each other.
What to do with the XML once you have it
The JUnit XML file is the input to every downstream workflow:
- GitHub Actions test summary — use the
dorny/test-reporteraction to render a test table directly in the Actions UI. - Flaky test detection — upload to BuildPulse to track which tests fail intermittently across many runs, not just the current one. Flaky tests are nearly impossible to identify from a single JUnit file; you need historical data.
- Code coverage correlation — if you're also generating coverage reports (
--coverage), you can correlate coverage data with test failures to find under-tested paths that produce recurring failures. - PR annotations — several Actions can parse JUnit XML and post inline PR comments pointing to the exact failing test.
The investment in getting jest-junit configured correctly pays off every time you don't have to scroll through 3,000 lines of CI logs to find out why a build broke.
Related posts