Stackvora.tech

Run Faster Tests by Splitting and Parallelizing

Inefficient test suites are a significant bottleneck in modern CI/CD pipelines, directly impacting developer productivity and deployment frequency.…

Inefficient test suites are a significant bottleneck in modern CI/CD pipelines, directly impacting developer productivity and deployment frequency. Long-running tests delay feedback, increase context switching, and can lead to less frequent code integration. This article details practical strategies for splitting and parallelizing test execution, focusing on GitHub Actions, Jest, and Pytest, to drastically reduce CI build times.

The goal isn't just faster CI runs, but more agile development cycles. Aim for individual CI jobs to complete in under 5 minutes; while CI minutes have a cost, the cumulative impact of developer waiting time is often far greater.

Understanding Test Splitting and Parallelization

Test splitting refers to dividing your entire test suite into smaller, independent subsets. Parallelization then involves executing these subsets concurrently across multiple CI agents or processes. This approach leverages available compute resources more effectively, turning a single, monolithic test run into several smaller, simultaneous ones.

There are generally two primary strategies for splitting tests:

  • Static Sharding: Pre-defining groups of tests that run together. This is simpler to implement but can lead to imbalanced shards if test execution times vary significantly.
  • Dynamic Sharding: Distributing tests based on historical execution times or real-time availability. This is more complex but offers better load balancing and can adapt to changing test suites.

Parallelizing with GitHub Actions Matrix Strategy

GitHub Actions' strategy.matrix is an excellent tool for defining variations of a job. It allows you to run the same job with different input parameters, making it ideal for parallelizing test shards. Each combination defined in the matrix will run as a separate job in parallel.

Basic Matrix Sharding for Tests

To implement sharding, you'll typically define a matrix variable, say shard, and specify the total number of shards. Your test runner then uses these values to determine which subset of tests to execute.

name: CI Pipeline

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false # Allows other shards to complete even if one fails
      matrix:
        shard: [1, 2, 3, 4] # Define 4 shards for parallel execution

    env:
      TOTAL_SHARDS: 4 # Total number of shards, passed to test runner

    steps:
    - uses: actions/checkout@v4
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'
    - run: npm ci

    - name: Run Shard ${{ matrix.shard }}
      run: npm test -- --shard=${{ matrix.shard }}/${{ env.TOTAL_SHARDS }}
      # The '--' passes arguments to the underlying test command (npm test)
      # For direct Jest: jest --shard=${{ matrix.shard }}/${{ env.TOTAL_SHARDS }}

In this example, four separate jobs will run concurrently, each handling one quarter of the test suite. The shard variable from the matrix and the TOTAL_SHARDS environment variable are passed to the test command.

Optimizing Shard Count

Determining the optimal number of shards requires experimentation. Too few shards leave CPU idle, too many can incur overhead from setup/teardown and context switching. A good starting point is 2-4x the number of CPU cores available on your CI runner, or empirically, the number that brings your longest shard under the 5-minute target.

Jest Test Sharding

Jest, a popular JavaScript testing framework, has built-in support for sharding as of version 27. Jest's sharding mechanism is dynamic, meaning it attempts to distribute test files based on execution time and file count to balance the load across shards.

Implementing Jest Sharding

As seen in the GitHub Actions example, Jest directly accepts a --shard argument:

jest --shard=CURRENT_SHARD_INDEX/TOTAL_SHARDS

Where CURRENT_SHARD_INDEX is 1-based (e.g., 1, 2, 3, 4) and TOTAL_SHARDS is the total number of shards. Jest uses a hashing algorithm and test file metadata to decide which files belong to each shard.

Example with npm script

If you're using npm test, ensure your script passes through the arguments correctly:

// package.json
{
  "name": "my-project",
  "version": "1.0.0",
  "scripts": {
    "test": "jest"
  },
  ...
}

Then, in your CI:

npm test -- --shard=${{ matrix.shard }}/${{ env.TOTAL_SHARDS }}

The -- separates npm arguments from the script arguments.

Jest Sharding Caveats

  • Version: Sharding is officially supported from Jest 27+. Ensure your Jest version is up to date.
  • Test Discoverability: Jest's sharding works best when all test files are discoverable at the start. If your tests are generated dynamically or depend on specific states, sharding might be less effective.
  • Balanced Shards: While Jest aims for balance, very long-running individual test files can still cause one shard to finish significantly later than others. Consider splitting such large test files if this becomes an issue.

Pytest Parallelization and Splitting

Pytest, the ubiquitous Python testing framework, doesn't have native sharding built-in like Jest. However, it offers powerful plugins for parallelization and splitting:

  • pytest-xdist: For parallelizing tests across multiple CPUs or remote hosts.
  • pytest-split: For splitting tests into a fixed number of evenly distributed parts.

Pytest-xdist for Parallel Execution

pytest-xdist allows you to run tests in parallel using multiple worker processes. It supports various distribution strategies.

Installation

pip install pytest-xdist

Usage with GitHub Actions

To use pytest-xdist with the GitHub Actions matrix, you'll combine its features with a method to select specific tests for each shard. A common approach is to use the --dist=loadgroup strategy or to combine it with pytest-split.

name: Python CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ['3.9', '3.10', '3.11'] # Example: Test across Python versions
        shard: [1, 2, 3, 4] # Define 4 shards

    env:
      TOTAL_SHARDS: 4

    steps:
    - uses: actions/checkout@v4
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v5
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install poetry # Or pip install -r requirements.txt
        poetry install # Or pip install -e . ".[dev]"
        pip install pytest pytest-xdist pytest-split # Install parallelization plugins

    - name: Run tests with pytest-split (Shard ${{ matrix.shard }})
      # pytest-split requires PYTEST_SPLIT_INDEX and PYTEST_SPLIT_TOTAL environment variables
      run: |
        PYTEST_SPLIT_INDEX=${{ matrix.shard }} \
        PYTEST_SPLIT_TOTAL=${{ env.TOTAL_SHARDS }} \
        pytest --split-by=file --durations=10 --strict-markers -n auto # -n auto leverages pytest-xdist
      # --split-by=file: splits based on files
      # --durations=10: show 10 slowest tests
      # --strict-markers: prevents typos in markers
      # -n auto: tells pytest-xdist to use as many worker processes as CPU cores

Pytest-split for Static Sharding

pytest-split is designed specifically for splitting a test suite into N parts. It can be particularly useful when you need more explicit control over which tests run in which shard or if you're not using pytest-xdist's parallelization on a single runner but rather distributing tests across multiple CI jobs (as in the GitHub Actions matrix). It works by setting environment variables.

Installation

pip install pytest-split

Usage

As demonstrated in the GitHub Actions snippet above, pytest-split relies on two environment variables:

  • PYTEST_SPLIT_INDEX: The 1-based index of the current shard (e.g., 1, 2, 3).
  • PYTEST_SPLIT_TOTAL: The total number of shards (e.g., 4).

When these are set, pytest-split intercepts the test collection phase and filters tests according to the specified shard. You can combine it with pytest -n auto to also parallelize within each shard if your CI runner has multiple cores.

Pytest Sharding Strategies and Trade-offs

Plugin/Method Splitting Strategy Parallelization Pros Cons
pytest-xdist -n auto Dynamic (by test/function) Multi-process (local) Good load balancing, easy setup for local parallelism. Can increase memory usage per worker; not direct sharding across CI jobs without other tools.
pytest-split Static (by file/module) External CI job distribution Explicit sharding for CI matrix, predictable distribution. Potential for imbalanced shards if test file run times vary greatly.
Custom Test Collection (advanced) Highly customizable Any Maximum flexibility, e.g., tag-based or duration-based. High implementation effort, difficult to maintain.

For most scenarios, combining GitHub Actions matrix with pytest-split for coarse-grained sharding across CI jobs and pytest -n auto (powered by pytest-xdist) for fine-grained parallelism within each job offers the best balance of simplicity and efficiency.

Common Pitfalls

  • Unbalanced Shards: If one shard consistently takes much longer than others, it becomes the bottleneck, negating much of the benefit. Investigate long-running tests within that shard. Consider dynamic sharding solutions or manually adjusting test file distribution if possible.
  • Shared Resources/State: Parallelizing tests that share or modify global state (e.g., databases, file systems) without proper isolation can lead to flaky tests or race conditions. Ensure tests are truly independent.
  • CI Runner Resource Limits: Running too many parallel jobs or shards on CI runners with limited CPU or memory can degrade performance or cause failures, even if the tests themselves are optimized. Monitor CI agent resource utilization.
  • Setup/Teardown Overhead: Each parallel job or worker might incur setup (e.g., dependency installation) and teardown overhead. For very fast tests, this overhead can sometimes outweigh the benefits of parallelization.
  • Test Dependencies: If tests in one shard depend on the successful completion of tests in another, parallelization will break the logic. Ensure your tests are atomic and independent.

Back to the knowledge base · Ask the AI assistant