Runners
5 min read

Jest JUnit output: how to make Jest results readable in CI

Jest prints beautiful output in your terminal and useless output in CI. Here's how to emit JUnit XML that your pipeline can actually parse — and what to watch for.

BuildPulse Team

July 20, 2026

Jest JUnit: readable test reports in CI | BuildPulse

Jest's default reporter is great in your terminal. Colors, a tidy summary, a red block right where the assertion blew up. Then you push to CI, a test fails, and you're staring at 4,000 lines of scrollback trying to find which of the 900 tests actually broke. Your CI provider shrugs. It has no idea Jest ran at all — it just saw a process exit non-zero.

That gap is what jest-junit closes. JUnit XML is the lingua franca of CI test reporting: GitHub Actions, GitLab, CircleCI, Buildkite, Jenkins, and roughly every dashboard on earth know how to read it. Jest doesn't emit it out of the box. So you bolt on a reporter, point your CI at the file, and suddenly failures show up as structured data instead of a wall of text.

This is the practical guide. Config, the gotchas that bite in parallel builds, and the part nobody tells you: JUnit XML is also how you catch the flaky tests hiding in that scrollback.

Why JUnit XML at all

JUnit XML is an ancient format — it predates most of the tools that consume it — but that's exactly why it won. It's boring and universal. A <testsuite> wraps a set of <testcase> elements, each with a name, a classname, a duration, and optionally a <failure> node with the stack trace.

Your CI ingests that file and renders per-test results: green checks, red failures with the actual error message, timing per case. On GitHub Actions with a test-reporter action, a failed assertion becomes an annotation right on the pull request diff. No scrollback archaeology.

The format also unlocks everything downstream. Test analytics, flaky-test detection, quarantine tooling, DORA-style metrics — they all start from structured per-test history, and JUnit XML is the cheapest way to produce it. If your reporting is a text log, none of that is possible. If it's XML, all of it is.

Setting up jest-junit

Install it as a dev dependency:

npm install --save-dev jest-junit

The cleanest way to wire it up is through the reporters array in your Jest config, keeping the default reporter so you still get readable local output:

// jest.config.js
module.exports = {
  reporters: [
    'default',
    ['jest-junit', {
      outputDirectory: './reports/junit',
      outputName: 'jest-junit.xml',
      ancestorSeparator: ' \u203a ',
      classNameTemplate: '{classname}',
      titleTemplate: '{title}',
    }],
  ],
};

Do not run it as a global CLI reporter with --reporters unless you also pass default — the flag replaces the whole array, and you'll silently lose your console output. That trips people up on day one.

A quick word on the templates. classNameTemplate and titleTemplate control how your describe/it names map onto the XML. The defaults are usually fine, but if your CI dashboard groups tests weirdly, this is the knob. ancestorSeparator is what joins nested describe blocks — the \u203a above is just a character so nested suites read cleanly instead of running together.

For a deeper walkthrough including a full GitHub Actions job, we wrote a companion piece: jest-junit: generate JUnit XML reports from Jest and surface them in CI.

Wiring it into GitHub Actions

Generating the XML is half the job. Surfacing it is the other half. Run your tests, then hand the file to a reporter action:

- name: Run tests
  run: npx jest --ci

- name: Publish test report
  uses: mikepenz/action-junit-report@v4
  if: always()
  with:
    report_paths: 'reports/junit/*.xml'

The if: always() matters. Without it, the publish step gets skipped the moment tests fail — which is precisely when you need the report. And --ci tells Jest to fail rather than write new snapshots, which you want in a pipeline that's supposed to be a gate, not a snapshot factory.

If you also upload the raw XML as an artifact, you get a durable record of every run. That's genuinely useful for regulated teams — an auditor asking "prove test X passed on the commit you shipped" wants a file, not a screenshot. We go deeper on that in test evidence for regulated teams.

The sharded-build gotcha

Here's where it gets interesting. Big suites shard across parallel jobs — jest --shard=1/4, --shard=2/4, and so on. Each shard runs jest-junit and each writes jest-junit.xml. If they share an output directory, they clobber each other, and your report ends up representing one-quarter of your tests. You'll swear tests vanished.

Give each shard a unique filename:

strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npx jest --ci --shard=${{ matrix.shard }}/4
    env:
      JEST_JUNIT_OUTPUT_NAME: jest-junit-${{ matrix.shard }}.xml

jest-junit reads config from environment variables too, which is handy for exactly this — you don't have to template your jest.config.js per shard. Then merge or glob all the files at report time. Most reporter actions accept a wildcard report_paths, so reports/junit/*.xml sweeps up every shard.

Sharding is also where CI cost quietly balloons. Every shard is a cold-ish runner spinning up. If your shards spend more time installing dependencies than running tests, that's a caching problem — see stop paying for cold caches. And if you're paying GitHub's per-minute rate for all that parallelism, it adds up fast; BuildPulse Runners run the same jobs at roughly half the cost.

The part JUnit XML is secretly great at: catching flakes

A single run's XML tells you what passed today. The gold is in the history. When you collect JUnit XML across every run, the same test that's green on one commit and red on the next — with no code change to explain it — lights up as flaky.

Jest gives you the raw material. Each <testcase> carries a stable name and a pass/fail status. Diff those across runs and non-deterministic tests fall out of the data. The usual suspects show up over and over: async race conditions, shared database state, and tests that assume execution order Jest doesn't guarantee.

The trap is doing this by eyeball. Nobody diffs XML by hand across 200 runs a day. This is where the reporting format becomes a pipeline: feed the XML to something that tracks per-test history, flags the flakes, and quarantines them so a known-bad test doesn't block a clean PR. That's exactly what BuildPulse does with the JUnit output you're already generating — no extra instrumentation, just point it at the file.

One caution worth repeating: don't reach for Jest's retry mechanism as your flake strategy. Retrying a flaky test until it passes doesn't fix anything — it just hides the signal and inflates your green rate. If a test needs three tries to pass, your CI signal is already lying to you, and the JUnit report is where the lie becomes visible. Fix the test; don't launder its failures.

The short version

Install jest-junit, keep the default reporter alongside it, give every shard a unique output filename, and publish with if: always(). That gets you readable failures in CI instead of a scrollback safari.

Then treat the XML as a data source, not just a log. One run tells you what broke. A month of runs tells you which tests you can't trust — and that's the question every CI pipeline is actually trying to answer.