Jest Sharding on GitHub Actions: Split Tests Across Parallel Workers

Jest Sharding on GitHub Actions: Split Tests Across Parallel Workers

A slow test suite kills developer flow. When Jest runs 800 tests sequentially it can take 8-12 minutes. With sharding across 4 parallel GitHub Actions runners, that drops to 2-3 minutes — same compute budget, 4× faster feedback. Here's how to set it up.

How Jest Sharding Works

Jest's --shard flag divides your test files into N roughly equal groups and runs only one group:

# Run shard 1 of 4 (first quarter of test files)
npx jest --shard=1/4

<span class="hljs-comment"># Run shard 2 of 4
npx jest --shard=2/4

Jest distributes by file, not by individual test. Files are sorted by duration from the last run (if Jest cache is available), putting slow files earlier to minimize load imbalance.

Basic GitHub Actions Setup

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false  # don't cancel other shards if one fails
      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 Jest shard ${{ matrix.shard }}/4
        run: npx jest --shard=${{ matrix.shard }}/4 --ci
        env:
          JEST_JUNIT_OUTPUT_FILE: test-results/junit-${{ matrix.shard }}.xml

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results-${{ matrix.shard }}
          path: test-results/

The strategy.matrix creates 4 parallel jobs. Each job runs one shard. Total wall-clock time is approximately total_test_time / 4 + overhead (checkout, npm install, etc.).

Caching Jest for Faster Shards

Jest's timing cache is critical for good shard balance. Without it, files are distributed by count, not by duration:

- name: Cache Jest
  uses: actions/cache@v3
  with:
    path: |
      ~/.jest-cache
      node_modules/.cache/jest-transform-cache-*
    key: jest-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
    restore-keys: |
      jest-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
      jest-${{ runner.os }}-

- name: Run Jest shard
  run: npx jest --shard=${{ matrix.shard }}/4 --ci --cacheDirectory=~/.jest-cache

With a warm cache, Jest uses historical timing data to spread slow tests evenly across shards.

Collecting Results Across Shards

After parallel shards finish, merge results for a unified report:

  collect-results:
    needs: test
    runs-on: ubuntu-latest
    if: always()
    steps:
      - uses: actions/checkout@v4

      - name: Download all shard results
        uses: actions/download-artifact@v4
        with:
          pattern: test-results-*
          merge-multiple: true
          path: test-results/

      - name: Publish combined JUnit report
        uses: dorny/test-reporter@v1
        with:
          name: Jest Tests (Combined)
          path: test-results/*.xml
          reporter: jest-junit

Dynamic Shard Count

Hard-coding 4 shards means changing one number in two places if you want to scale. Use a matrix that derives the shard argument:

strategy:
  matrix:
    shard-index: [0, 1, 2, 3]
  fail-fast: false

steps:
  - name: Run Jest
    run: npx jest --shard=$(( ${{ matrix.shard-index }} + 1 ))/4

Or let the matrix count define the total:

strategy:
  matrix:
    shard: ["1/4", "2/4", "3/4", "4/4"]

steps:
  - run: npx jest --shard=${{ matrix.shard }}

Excluding Files from Sharding

Some tests shouldn't be sharded — integration tests that need a specific database state, or tests that should run last:

# In your jest.config.js
module.exports = {
  projects: [
    {
      displayName: <span class="hljs-string">'unit',
      testMatch: [<span class="hljs-string">'**/*.unit.test.ts'],
      // unit tests go through sharding
    },
    {
      displayName: <span class="hljs-string">'integration',
      testMatch: [<span class="hljs-string">'**/*.integration.test.ts'],
      // integration tests run separately
    },
  ],
};
jobs:
  unit-tests:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - run: npx jest --selectProjects unit --shard=${{ matrix.shard }}/4

  integration-tests:
    steps:
      - run: npx jest --selectProjects integration

Measuring Shard Balance

After a few runs, check if shards are balanced. Unbalanced shards waste compute — one shard finishes in 1 minute while another takes 8:

# From GitHub Actions job durations, or locally:
<span class="hljs-keyword">for i <span class="hljs-keyword">in 1 2 3 4; <span class="hljs-keyword">do
  <span class="hljs-built_in">echo <span class="hljs-string">"Shard $i:"
  npx jest --shard=<span class="hljs-variable">$i/4 --listTests <span class="hljs-pipe">| <span class="hljs-built_in">wc -l
<span class="hljs-keyword">done

If shard durations vary significantly:

  1. Ensure Jest cache is warmed (timing data exists)
  2. Consider splitting outlier test files into smaller pieces
  3. Increase shard count to reduce imbalance

Handling Test Database in Shards

When tests share a database, parallel shards can conflict. Solutions:

Option 1: Per-shard database

env:
  DATABASE_URL: postgres://localhost/test_db_shard_${{ matrix.shard }}

steps:
  - run: |
      createdb test_db_shard_${{ matrix.shard }}
      npx prisma migrate deploy
      npx jest --shard=${{ matrix.shard }}/4

Option 2: Test isolation via transactions

// jest.setup.ts
beforeEach(async () => {
  await db.query('BEGIN');
});

afterEach(async () => {
  await db.query('ROLLBACK');
});

Transaction rollback is the cleanest approach — each test sees an isolated state without needing separate databases.

Expected Speedup

Sharding scales roughly linearly up to the point where overhead dominates:

Shards Test Time Overhead Total Wall Time
1 8 min 1 min 9 min
2 4 min 1 min 5 min
4 2 min 1 min 3 min
8 1 min 1 min 2 min

Beyond 8 shards, checkout/install overhead becomes significant. GitHub Actions has a 20-job matrix limit per workflow.

Summary

Jest sharding splits test files across parallel runners using --shard=N/M. In GitHub Actions, a matrix strategy creates parallel jobs automatically. The key requirements: fail-fast: false to get all results even if one shard fails, cached Jest timing data for balanced distribution, and an artifact collection step to merge JUnit results. A 4-shard setup typically delivers a 3-4× wall-clock speedup on typical suites.

Read more