Runners
4 min read

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: testsuitestestsuitetestcase. 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-reporter action 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.