How to Run Tests in Parallel
Parallelism is the single most effective technique for keeping continuous testing pipelines fast as test suites grow. When a team starts with 50 tests that run in 2 minutes, serial execution is fine. When that suite grows to 500 tests taking 20 minutes, developers start context-switching while waiting for results, and the feedback loop that makes continuous testing valuable degrades. Parallelism restores fast feedback at scale.
There are two layers of parallelism available, and the best results come from combining both. Framework-level parallelism distributes tests across multiple worker processes on a single machine. CI-level parallelism distributes tests across multiple machines (or containers) that run independently. Together, a test suite running on 4 CI machines with 4 workers each has 16 parallel streams of test execution.
Ensure Test Isolation
Parallel execution only works when tests are independent. Two tests running simultaneously must not interfere with each other through shared state, shared files, shared database rows, or shared external resources. If test A writes a user record with email "test@example.com" and test B reads users from the database expecting a specific count, running them in parallel produces unpredictable results.
The rules for test isolation are straightforward:
- Each test creates its own data. Never rely on seed data, shared fixtures, or state left by previous tests. Generate unique test data with randomized identifiers (UUIDs work well) so parallel tests never collide.
- No shared mutable state. Global variables, singleton instances, shared caches, and shared file paths are all sources of parallel test failures. If a test needs to modify global state, use dependency injection or test-scoped overrides.
- Isolated database access. Three approaches work: separate database schemas per worker, transaction rollback after each test, or test containers that give each worker its own database instance.
- No port conflicts. If tests start a local server, each must use a unique port. Most test frameworks provide random port assignment, or you can use port 0 (which the OS assigns dynamically).
- No order dependence. Tests must pass regardless of which order they run in. If test B depends on a side effect of test A, that dependency is a bug in the test design, not a feature.
A practical way to verify isolation before enabling parallelism: shuffle the test order randomly (Jest's --randomize flag, pytest's pytest-randomly plugin) and run the suite multiple times. If tests fail intermittently when shuffled, they have order dependencies that need fixing first.
Enable Framework Level Parallelism
Most modern test frameworks support parallel execution natively. Enabling it is usually a configuration change, not a code change.
Playwright
Playwright Test runs test files in parallel by default, using a number of worker processes equal to half the CPU cores. Each worker runs a separate test file, and tests within a single file run sequentially (unless you opt in to intra-file parallelism with test.describe.configure({ mode: 'parallel' })). Configure the worker count in playwright.config.ts:
// playwright.config.ts
export default defineConfig({
workers: process.env.CI ? 4 : undefined,
fullyParallel: true,
retryOnFailure: true,
});
The fullyParallel option runs individual tests within files in parallel, not just files themselves. This maximizes parallelism but requires that all tests within a file are fully isolated from each other. In CI environments, setting a fixed worker count (like 4) prevents the framework from over-allocating workers on machines with limited memory.
Jest
Jest runs test files across worker processes by default, using the --maxWorkers flag to control concurrency. In CI environments, --maxWorkers=2 or --maxWorkers=50% prevents memory pressure on shared runners:
# Run with 4 workers
npx jest --maxWorkers=4
# Use 50% of available CPUs
npx jest --maxWorkers=50%
# Run tests sequentially (for debugging)
npx jest --runInBand
Jest also supports --shard for distributing tests across multiple CI machines: npx jest --shard=1/3 runs the first third of test files, --shard=2/3 runs the second third, and so on. This is CI-level parallelism triggered from the framework.
pytest with pytest-xdist
Python's pytest does not parallelize by default, but the pytest-xdist plugin adds multi-process execution:
# Install
pip install pytest-xdist
# Run with 4 workers
pytest -n 4
# Auto-detect CPU count
pytest -n auto
# Distribute by file (default) or by test
pytest -n 4 --dist loadfile
The --dist loadfile option keeps all tests from the same file on the same worker, which is useful when tests within a file share expensive setup (like database connections) through fixtures. The --dist loadscope option groups by test module or class.
JUnit 5
JUnit 5 supports parallel execution through configuration in junit-platform.properties:
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.config.fixed.parallelism=4
Individual test classes or methods can opt out of parallelism with the @Isolated annotation when they genuinely need exclusive access to a shared resource.
Add CI Level Parallelism
Framework-level parallelism is limited by the resources of a single machine. CI-level parallelism distributes tests across multiple machines (or containers) that GitHub Actions, Jenkins, GitLab CI, or CircleCI manage independently.
GitHub Actions Matrix Strategy
The matrix strategy runs the same job multiple times with different parameter values. For test sharding, use a shard parameter:
jobs:
test:
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test --shard=${{ matrix.shard }}
This creates four parallel jobs, each running a quarter of the test files. Playwright's built-in sharding distributes files evenly, and each shard produces its own test report. The fail-fast: false option ensures all shards complete even if one fails, so you see the full picture of test health.
Jenkins Parallel Stages
Jenkins pipelines support parallel stages within a Jenkinsfile:
pipeline {
agent any
stages {
stage('Test') {
parallel {
stage('Shard 1') {
agent { docker { image 'node:20' } }
steps {
sh 'npm ci'
sh 'npx playwright test --shard=1/3'
}
}
stage('Shard 2') {
agent { docker { image 'node:20' } }
steps {
sh 'npm ci'
sh 'npx playwright test --shard=2/3'
}
}
stage('Shard 3') {
agent { docker { image 'node:20' } }
steps {
sh 'npm ci'
sh 'npx playwright test --shard=3/3'
}
}
}
}
}
}
Each parallel stage runs on its own agent (Docker container or node), executing independently. Jenkins waits for all parallel stages to complete before proceeding to the next stage.
CircleCI Test Splitting
CircleCI has a built-in test splitting mechanism that distributes test files across parallel containers based on historical timing data:
jobs:
test:
parallelism: 4
steps:
- checkout
- run:
name: Run tests
command: |
TESTFILES=$(circleci tests glob "tests/**/*.test.js" | circleci tests split --split-by=timings)
npx jest $TESTFILES
The --split-by=timings option uses historical execution data to distribute test files so that all containers finish at approximately the same time. This is more effective than splitting by file count because test files vary dramatically in execution time.
Balance the Workload
The total execution time of a parallelized test suite is determined by the slowest shard. If you split 100 tests across 4 workers and one worker gets all the slow integration tests while the others get fast unit tests, the slow worker becomes the bottleneck and the other three sit idle waiting.
Three strategies balance work across shards:
Timing based splitting. Record how long each test file takes, then distribute files across shards so each shard's total time is roughly equal. CircleCI does this automatically with --split-by=timings. Playwright's sharding algorithm balances by file, not by timing, so manual splitting may be needed for very uneven test distributions.
Granular test splitting. Instead of splitting by file, split by individual test. This provides finer-grained balance because a single slow file can be distributed across multiple workers. pytest-xdist does this by default, distributing individual test functions across workers rather than entire files.
Dynamic distribution. Workers pull tests from a shared queue rather than receiving a fixed assignment upfront. When a worker finishes a test, it pulls the next one from the queue. This self-balancing approach ensures all workers stay busy until the queue is empty. Knapsack Pro is a commercial tool that implements this pattern for multiple frameworks.
Monitor individual shard execution times after enabling parallelism. If one shard consistently takes 3x longer than others, the split needs rebalancing. Most CI platforms display individual job durations in their UI, making bottlenecks visible at a glance.
Monitor and Optimize
Parallel test execution introduces its own category of issues that require monitoring.
Resource contention. Four browser instances running simultaneously on a 2-core CI runner will compete for CPU and memory, causing all tests to slow down rather than speeding up. Match the worker count to available resources: 1 browser worker per core is a reasonable starting point for Playwright tests, with adjustments based on actual CPU and memory usage.
Flakiness from parallelism. Tests that pass when run serially but fail when run in parallel almost always have a shared state issue: a hardcoded port, a global variable, a file path collision, or a database race condition. These bugs exist in serial execution too, they just do not manifest because tests happen to run in a compatible order. Fix the isolation issue rather than reducing parallelism.
Report aggregation. When tests are split across multiple CI jobs, each job produces its own report. Combine them into a unified view using artifact merging: each shard uploads its results as an artifact, and a final job downloads all artifacts and generates a combined report. Playwright's merge-reports command handles this natively:
npx playwright merge-reports --reporter html ./all-blob-reports
Cost vs. speed tradeoff. More parallel workers means faster results but higher CI costs. Four shards running for 10 minutes each consume 40 minutes of CI quota, the same as one job running for 40 minutes. The benefit is wall-clock time (10 minutes vs 40 minutes), not compute cost. Find the sweet spot where feedback is fast enough without overspending on CI minutes.
Real World Example: From 45 Minutes to 8 Minutes
Consider a web application with 200 unit tests (2 minutes serial), 80 integration tests (8 minutes serial), and 120 Playwright E2E tests (35 minutes serial). Total serial time: 45 minutes. This is too slow for a continuous testing pipeline where developers expect feedback on every PR.
Step 1: Run unit tests and integration tests as separate parallel jobs. Unit tests finish in 2 minutes, integration tests in 8 minutes. These run concurrently, so this stage takes 8 minutes (the longer of the two).
Step 2: Shard E2E tests across 5 parallel CI jobs using Playwright's --shard option. Each shard runs approximately 24 test files, taking about 7 minutes instead of 35.
Step 3: Enable 2 Playwright workers per shard (10 total browser processes across 5 machines). Each shard now takes about 4 minutes.
The result: unit/integration tests take 8 minutes (parallel), E2E tests take 4 minutes (parallel, running after unit/integration pass). Total wall-clock time for the full pipeline drops from 45 minutes to roughly 12 minutes. With some caching optimization and lint checks running in an even earlier parallel stage, this pipeline comes in under 10 minutes.
Combine framework-level parallelism (multiple workers per machine) with CI-level parallelism (multiple machines via matrix builds or sharding) to cut test execution time dramatically. The prerequisite is test isolation: each test must create its own data, avoid shared mutable state, and pass regardless of execution order.