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/4Jest 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-cacheWith 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-junitDynamic 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 ))/4Or 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 integrationMeasuring 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">doneIf shard durations vary significantly:
- Ensure Jest cache is warmed (timing data exists)
- Consider splitting outlier test files into smaller pieces
- 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 }}/4Option 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.