Runners
7 min read

jest-junit done right: getting trustworthy JUnit XML out of Jest

jest-junit looks like a two-line install. Then shards overwrite each other, crashed suites vanish, and test identities churn. Here's the config that holds up in CI.

BuildPulse Team

August 31, 2026

Listen

jest-junit: JUnit XML reports for Jest in CI | BuildPulse Blog

The two-line install that quietly lies to you

Every Jest shop eventually needs JUnit XML. Your CI wants it for annotations, your test analytics tool wants it for history, your flaky-test detection wants it for verdicts, and if you're in a SOC2 or change-management environment, your auditors want evidence that the gate actually ran. Jest doesn't speak JUnit natively, so you reach for jest-junit, add two lines to your config, see a green build with an XML file in it, and move on.

Here's the uncomfortable part: the default jest-junit configuration produces XML that is technically valid and practically misleading. Test identities churn every time someone renames a describe block. Suites that crash before running a single test can vanish from the report entirely. Sharded jobs overwrite each other's output. And Jest-level retries silently launder flaky tests into clean passes before the XML is ever written.

None of this shows up as a red build. It shows up six weeks later, when your test history is garbage and nobody can say why. If your platform team owns CI, this post is the config review you wish someone had done for you.

Why JUnit XML is still the contract

JUnit XML is the FORTRAN of test reporting: old, ugly, and absolutely everywhere. GitHub Actions annotation tools parse it. Test-splitting tools use its time attributes to balance shards. Flaky-test platforms like BuildPulse ingest it to build pass/fail history per test. When the XML is wrong, everything downstream is wrong, and downstream is where the decisions get made: which tests to quarantine, which shard layout to use, whether the release gate actually passed.

So treat the reporter config like production code, because functionally it is. It's the serialization layer for your entire quality signal.

The baseline config

Install the reporter and wire it up alongside the default reporter (keep default, or your local terminal output disappears and your engineers will hunt you down):

npm install --save-dev jest-junit
// jest.config.js
module.exports = {
  reporters: [
    'default',
    [
      'jest-junit',
      {
        outputDirectory: 'reports/junit',
        outputName: 'jest-junit.xml',
        suiteNameTemplate: '{filepath}',
        classNameTemplate: '{filepath}',
        titleTemplate: '{title}',
        ancestorSeparator: ' > ',
        addFileAttribute: 'true',
        reportTestSuiteErrors: 'true',
        includeConsoleOutput: 'true',
      },
    ],
  ],
};

Two quirks worth knowing before we get to the why. First, several jest-junit options take the string 'true', not the boolean true. This trips up everyone exactly once. Second, every option can be overridden with a JEST_JUNIT_* environment variable, which turns out to be the key to sane sharding later.

That config is not arbitrary. Each line fixes a specific failure mode.

Stable test identity, or why {filepath} beats the defaults

Out of the box, jest-junit builds classname from a template that concatenates ancestor describe titles with the test title, and names every testsuite a generic string. That means the identity of a test, the thing every downstream tool keys history on, is a mashup of human-written prose.

Consider what happens when a well-meaning engineer renames describe('CheckoutFlow') to describe('Checkout flow'). Semantically, nothing changed. In the XML, every test in that block just became a brand-new test with zero history, and the old tests appear to have been deleted. Your flaky-test detection resets to a cold start. Your quarantine list points at test names that no longer exist. Your timing-based shard balancer forgets everything it learned.

Anchoring suiteNameTemplate and classNameTemplate to {filepath} gives you identity that only changes when a file actually moves, which is rare, deliberate, and visible in code review. Keep the human-readable ancestor titles in the test name via titleTemplate and ancestorSeparator, where churn is cosmetic instead of destructive.

addFileAttribute: 'true' adds a file attribute to each testcase. GitHub Actions annotation tools and most test analytics platforms use it to link a failure back to the source file, which is the difference between an annotation on the right line of the right file and a failure notification that says, in effect, "something, somewhere."

The output looks like this:

<testsuite name="src/checkout/payment.test.ts" tests="3" failures="1" time="4.211">
  <testcase
    classname="src/checkout/payment.test.ts"
    name="Checkout flow > declined card > shows retry prompt"
    file="src/checkout/payment.test.ts"
    time="1.87">
    <failure>Expected element to be visible...</failure>
  </testcase>
</testsuite>

Stable classname, descriptive name, actionable file. That's the whole game.

The vanishing suite problem

Here is the sharpest edge in the whole tool. When a test file fails to load at all (a broken import, a syntax error introduced by a bad merge, an out-of-memory kill during module init), Jest reports a suite-level error rather than individual test failures. Historically, jest-junit simply omitted these suites from the XML unless you opted in.

Think about what that means downstream. The build is red, sure. But the XML says nothing about payment.test.ts; it's just absent. Any tool diffing test results between runs concludes those tests were removed. If someone reruns the job and the OOM doesn't recur, the tests reappear, and now your history shows a phantom delete-and-restore instead of what actually happened: a suite crashed. In a compliance context, "the report has no record of these tests executing" is not a sentence you want to say out loud.

reportTestSuiteErrors: 'true' fixes this by emitting an errored testsuite with the failure message. Crashed suites become visible, diffable events instead of silent gaps. There is no scenario where you want this off in CI.

Sharding without shards eating each other

Once your suite is big enough to shard with jest --shard, the default single outputName becomes a liability. Every shard writes jest-junit.xml. If shards share a workspace or you merge artifacts naively, the last writer wins and the other shards' results evaporate. Partial evaporation is worse than total failure, because the build stays green and the report just gets mysteriously thinner.

Use the environment-variable override to give each shard its own file:

jobs:
  test:
    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
        run: npx jest --shard=${{ matrix.shard }}/4
        env:
          JEST_JUNIT_OUTPUT_NAME: junit-shard-${{ matrix.shard }}.xml
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: junit-shard-${{ matrix.shard }}
          path: reports/junit/

Two details in that workflow do more work than they appear to. fail-fast: false keeps one shard's failure from cancelling the others, so you always get complete results instead of a report with holes shaped like whatever failed first. And if: always() on the upload step is non-negotiable: without it, the artifact upload is skipped precisely when tests fail, which is precisely when you need the XML. I have watched teams debug "missing test results" for a full afternoon before noticing the upload step showed as skipped, in gray, on every red build going back months.

If you're sharding to claw back wall-clock time, the runner itself is usually the other half of that equation; we covered the layout tradeoffs in our post on test sharding in GitHub Actions.

Retries: where flakiness goes to hide

Jest's jest.retryTimes(2) (with the jest-circus runner) retries failing tests in-process. Convenient, and dangerous in a specific way: jest-junit serializes the final outcome. A test that failed twice and passed on the third attempt is written to XML as a plain, unremarkable pass. No flaky marker, no retry count, nothing.

That means blanket retries don't just hide flakiness from your engineers; they scrub it from the record before any downstream tool can see it. Your flaky-test platform can't detect a flake that was laundered into a pass before serialization. Your XML says the suite is healthy while retry counts quietly climb, along with your CI bill, since every retry is paid compute.

My strong recommendation: don't set global retryTimes. Let flaky tests fail honestly in the XML so detection tooling can see the intermittent pattern across runs, then quarantine the ones that are genuinely flaky so they stop blocking merges while someone fixes them. Quarantine keeps the evidence and removes the pain. Retries remove the evidence and keep the cost.

If you must retry a specific known-bad test as a stopgap, scope jest.retryTimes inside that one file and leave a linked ticket. A stopgap with an owner is a plan; a global retry policy is a confession.

Feeding the XML to something useful

Once every shard reliably emits well-formed XML with stable identities, the payoff is that downstream tools finally have something trustworthy to chew on. This is exactly the shape of input BuildPulse ingests to build per-test pass/fail history and flag flaky tests automatically; the Jest setup docs walk through pointing the uploader at your reports/junit directory, and multi-shard runs work out of the box since each shard's file carries stable filepath-based identities.

But the config above is worth doing even if you never send the XML anywhere fancy. Accurate reports are the substrate for every CI decision you'll make this year.

The checklist

Before you trust your jest-junit output, verify:

  • Identity is filepath-anchored. suiteNameTemplate and classNameTemplate use {filepath}, so renamed describe blocks don't reset test history.
  • Crashed suites are visible. reportTestSuiteErrors: 'true', and you've tested it by temporarily breaking an import.
  • Shards don't collide. JEST_JUNIT_OUTPUT_NAME is unique per shard, and fail-fast: false keeps results complete.
  • Uploads survive failure. Every artifact or report step has if: always().
  • Retries aren't laundering flakes. No global retryTimes; flaky tests fail visibly and get quarantined instead.
  • file attributes exist. addFileAttribute: 'true' so annotations land on real source lines.

Twenty minutes of config review, and your JUnit XML goes from a compliance checkbox to the most honest artifact in your pipeline. Given how many decisions ride on it, honest is the bar.

Stop guessing which tests you can trust

BuildPulse finds your flaky tests, ranks them by the engineering time they cost, and lets you quarantine the worst in one click. See results on your first build.

Free to start · No credit card required · Setup is a single CI step