Parallel Test Execution Strategies in CI Pipelines

Parallel Test Execution Strategies in CI Pipelines

A test suite that takes 45 minutes to run is a test suite that gets skipped. Developers waiting for feedback lose flow, start stacking commits, and begin treating CI as a formality rather than a safety net. Parallel test execution is the most impactful lever you have for keeping test suites viable as they grow.

This post covers the core strategies for parallelizing tests in CI, with concrete implementations for the most common tools and platforms.

Why Parallelization Is Different in CI

Local parallelization (using all your laptop's cores) and CI parallelization (splitting work across multiple machines) are fundamentally different problems.

Local: you have one machine, fixed resources, and you're splitting a test suite across available CPU cores. The overhead is low, communication is cheap, and you're optimizing for CPU utilization.

CI: you have multiple machines (agents/runners) that share nothing. Splitting work means deciding which tests run on which machine, coordinating results afterward, and dealing with startup overhead for each machine. The optimization target is wall-clock time, not CPU efficiency.

The Three Parallelization Approaches

1. Static Splitting (Index-Based)

The simplest approach: divide tests into N groups and assign each group to one runner. Most CI platforms support this natively.

GitHub Actions:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - name: Run tests (shard ${{ matrix.shard }} of 4)
        run: npx jest --shard=${{ matrix.shard }}/4

This runs 4 parallel jobs, each executing one quarter of your test suite. Jest's --shard flag handles the splitting automatically by distributing test files evenly.

Pytest equivalent:

- name: Run tests
  run: pytest --split=${{ matrix.shard }} --splits=4 tests/

This requires the pytest-split plugin, which also distributes test files evenly across workers.

The limitation of static splitting: uneven test files cause uneven execution times. If shard 1 gets three fast tests and shard 4 gets one slow integration test, you're still waiting for shard 4 even though the other three finished minutes ago. This is the skew problem.

2. Dynamic Load Balancing

Dynamic load balancing uses historical timing data to distribute tests so each shard finishes at roughly the same time. This requires storing test timing data between runs.

With Jest and timing data:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4

      - name: Restore test timing data
        uses: actions/cache@v4
        with:
          path: .jest-timing.json
          key: jest-timing-${{ github.ref }}
          restore-keys: jest-timing-

      - run: npm ci

      - name: Run tests
        run: npx jest --shard=${{ matrix.shard }}/4 --json --outputFile=test-results-${{ matrix.shard }}.json

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

Then in a follow-up job, aggregate timing data from all shards to build jest-timing.json for the next run. The --shard flag in newer versions of Jest can use this timing data to create balanced shards rather than splitting by file count.

Pytest with timing-based splitting:

# Run with timing collection
pytest --store-durations --durations-path=.test-durations.json tests/

# Subsequent runs use timing data for balanced splitting
pytest --split=1 --splits=4 --durations-path=.test-durations.json tests/

3. Test Queue (Worker Pool)

The most sophisticated approach: a central queue holds all tests, and workers pull from it dynamically. Workers that finish fast pick up more tests automatically, eliminating skew entirely.

This is the model used by tools like Knapsack Pro, BuildKite's Test Splitting, and RSpec's parallel_tests gem.

Example with a simple Redis-backed queue (Node.js):

// test-coordinator/queue.js
const Redis = require('ioredis');

async function buildTestQueue(testFiles) {
  const redis = new Redis(process.env.REDIS_URL);
  const queueKey = `test-queue:${process.env.CI_RUN_ID}`;
  
  // Push all test files to the queue
  await redis.rpush(queueKey, ...testFiles);
  await redis.expire(queueKey, 3600); // 1 hour TTL
  
  console.log(`Queued ${testFiles.length} test files`);
  await redis.quit();
}

async function getNextTest() {
  const redis = new Redis(process.env.REDIS_URL);
  const queueKey = `test-queue:${process.env.CI_RUN_ID}`;
  
  const testFile = await redis.lpop(queueKey);
  await redis.quit();
  return testFile;
}

Workers repeatedly call getNextTest() until the queue is empty. This approach works especially well when test execution times vary widely — fast workers automatically handle more tests.

Framework-Specific Parallelization

Jest

Jest has built-in multi-process parallelization for running within a single machine:

// jest.config.js
module.exports = {
  maxWorkers: '50%',        // Use 50% of available CPUs
  // OR
  maxWorkers: 4,            // Fixed number of workers
  
  // For CI where you want maximum parallelism
  maxWorkers: process.env.CI ? '100%' : '50%',
};

For cross-machine parallelism, combine with the --shard flag as shown above.

Isolating tests that can't run in parallel:

// jest.config.js
module.exports = {
  projects: [
    {
      displayName: 'unit',
      testMatch: ['**/*.unit.test.js'],
      maxWorkers: 4,
    },
    {
      displayName: 'integration',
      testMatch: ['**/*.integration.test.js'],
      maxWorkers: 1,  // Run serially - these need DB access
      runner: 'jest-serial-runner',
    },
  ],
};

Pytest

# pytest.ini
[pytest]
addopts = -n auto  # Use all available CPUs (requires pytest-xdist)
# Specific worker count
pytest -n 4 tests/

# Distribute by test file (default)
pytest -n 4 --dist=loadfile tests/

# Distribute so each worker gets roughly equal work
pytest -n 4 --dist=loadscope tests/

# Truly dynamic distribution
pytest -n 4 --dist=load tests/

The --dist flag controls how tests are distributed to workers:

  • loadfile: all tests from one file go to the same worker (useful when tests in a file share setup)
  • loadscope: tests in the same class/module go to the same worker
  • load: purely dynamic — workers pull the next available test

Go Tests

Go's built-in test runner supports parallelism at multiple levels:

# Run test packages in parallel (default)
go test ./... -p 4

# Within a package, mark individual tests as parallelizable
func TestUserCreation(t *testing.T) {
    t.Parallel() // This test can run concurrently with other Parallel tests
    
    // test code
}

func TestEmailSending(t *testing.T) {
    t.Parallel()
    
    // test code
}
# Control concurrency within packages
go test ./... -parallel 8

Handling Shared State

Parallel tests fail in surprising ways when they share state. The most common culprits:

Database state: Tests that create, modify, and delete the same records will step on each other.

Solutions:

  1. Separate databases per worker: Each parallel worker gets its own database instance.
  2. Transaction rollback: Wrap each test in a transaction and roll it back after.
  3. Test-specific prefixes: Use worker ID or test name as a prefix for all created resources.
// Transaction-based isolation for Node.js/Postgres
beforeEach(async () => {
  await db.query('BEGIN');
});

afterEach(async () => {
  await db.query('ROLLBACK');
});
# pytest-django handles this automatically with @pytest.mark.django_db
@pytest.mark.django_db(transaction=False)  # Wraps in transaction + rollback
def test_user_creation():
    user = User.objects.create(username='test')
    assert user.id is not None

Port conflicts: Integration tests that start servers on hardcoded ports will conflict.

// Use random available ports
const server = app.listen(0); // Port 0 = OS assigns available port
const port = server.address().port;

File system: Tests writing to the same file paths will corrupt each other.

import tempfile
import pytest

@pytest.fixture
def tmp_output_dir(tmp_path):
    # pytest's tmp_path fixture gives each test a unique temporary directory
    return tmp_path / "output"

Aggregating Results from Parallel Runs

When tests run across multiple machines, you need to collect all results into one report.

GitHub Actions — combining JUnit results:

  aggregate-results:
    needs: test
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Download all test results
        uses: actions/download-artifact@v4
        with:
          pattern: test-results-*
          merge-multiple: true
          path: all-results/

      - name: Publish combined test report
        uses: dorny/test-reporter@v1
        with:
          name: 'Combined Test Results'
          path: 'all-results/**/*.xml'
          reporter: 'java-junit'

Coverage aggregation:

      - name: Merge coverage reports
        run: |
          npm install -g nyc
          nyc merge coverage-reports/ merged-coverage.json
          nyc report --reporter=lcov --temp-dir=merged-coverage.json

Measuring Parallelization Effectiveness

The metric to track is wall-clock time vs. total CPU time. If your tests take 40 minutes serially and 12 minutes with 4 parallel shards, that's good but not great — ideal would be 10 minutes (40/4). The 2-minute gap is your coordination overhead (startup, result collection, skew).

Track these over time:

  • Slowest shard time — this is your bottleneck
  • Skew — difference between fastest and slowest shard
  • Flakiness rate — parallel tests that were stable serially but flake in parallel indicate shared state problems

When skew exceeds 20% of total test time, it's worth investing in better splitting (timing-based or dynamic queuing). When flakiness increases after enabling parallelism, hunt for shared state rather than adding retries.

Parallelization isn't a one-time setup — it needs maintenance as your test suite grows. Revisit your sharding strategy quarterly, and treat test skew as a performance bug worth fixing.

Read more

Start now free