---
title: "Shard tests in GitHub Actions to parallelize a long suite"
description: "Split a long test job into parallel shards with a matrix. How to shard Playwright, Jest, Vitest, and pytest in GitHub Actions, with copyable YAML."
url: https://starsling.dev/best-practices/github-actions/shard-tests
canonicalUrl: https://starsling.dev/best-practices/github-actions/shard-tests
---

# Shard tests across parallel jobs in GitHub Actions

Best practice: Parallelization. Last updated: 2026-07-15

Split one long test job into parallel shards with a matrix so the suite finishes in a fraction of the wall-clock time.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [Seen in the wild](#seen-in-the-wild)
- [Why it matters](#why-it-matters)
- [When to use](#when-to-use)
- [Verify on your repo](#verify)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

A test job that would run for many minutes as a single sequential run is split into N parallel shards via a `matrix`, each running its slice with the framework's native sharding flag (`--shard` for Playwright/Vitest/Jest, `--partition` for cargo-nextest, `pytest-split` for pytest). The critical path drops toward `total / N` plus per-job setup.

_Matrix sharding across 4 parallel jobs_

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      # Node 24 action versions. As of 2026-07-14, Playwright's docs still show the Node 20
      # v4/v5 (playwright.dev/docs/test-sharding); these are the current equivalents, same behavior.
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v6
        with:
          node-version: 22
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      # strategy.job-total, not a literal 4: the denominator is the matrix size. Hardcode it
      # and someone shrinks the matrix to [1, 2, 3] one day, the flag still says /4, and a
      # quarter of the suite silently never runs. Green CI, untested code.
      - run: npx playwright test --shard=${{ matrix.shard }}/${{ strategy.job-total }} --reporter=blob

      # Each shard emits its own partial blob report. Keep them all, even when
      # a shard fails, so the merge job below can see why.
      - name: Upload blob report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v7
        with:
          name: blob-report-${{ matrix.shard }}
          path: blob-report
          retention-days: 1

      # OTHER FRAMEWORKS: only the RUN step is portable. The artifact path and the merge job
      # below are Playwright's, and copying them for another runner is how you get a green
      # build with an empty report.
      #
      #   Jest, no merge step; each shard reports on its own:
      #     - run: npx jest --shard=${{ matrix.shard }}/${{ strategy.job-total }}
      #
      #   Vitest, same contract as Playwright but ITS OWN paths. Note the reporter flag: without
      #   it the shards pass, emit no blob, and the merge finds nothing. Vitest writes blobs to a
      #   HIDDEN directory, so the upload also needs `path: .vitest-reports/*` and
      #   `include-hidden-files: true` (upload-artifact defaults that to false and would upload
      #   zero files), and the merge is `npx vitest run --merge-reports`:
      #     - run: npx vitest run --reporter=blob --shard=${{ matrix.shard }}/${{ strategy.job-total }}
      #
      #   pytest-split. Commit a .test_durations file (`pytest --store-durations`), or the groups
      #   are balanced by assumed-average time and the shards skew:
      #     - run: pytest --splits ${{ strategy.job-total }} --group ${{ matrix.shard }}
      #
      #   cargo-nextest >= 0.9.127 (older versions use hash:m/n):
      #     - run: cargo nextest run --partition slice:${{ matrix.shard }}/${{ strategy.job-total }}

  # Playwright only. Without this job the run ends with 4 partial reports
  # instead of one. It runs even if a shard failed, so failures still show up.
  merge-reports:
    if: ${{ !cancelled() }}
    needs: [test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v6
        with:
          node-version: 22
      - run: npm ci
      - name: Download blob reports
        uses: actions/download-artifact@v7
        with:
          path: all-blob-reports
          pattern: blob-report-*
          merge-multiple: true
      - run: npx playwright merge-reports --reporter html ./all-blob-reports
      - name: Upload HTML report
        uses: actions/upload-artifact@v7
        with:
          name: html-report--attempt-${{ github.run_attempt }}
          path: playwright-report
          retention-days: 14
```

<a id="avoid-this"></a>

## Avoid this

The whole suite runs on one runner, so the PR waits on a single long pole.

_One sequential job for the whole suite_

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: npx playwright test   # every spec, one runner, one long pole
```

<a id="seen-in-the-wild"></a>

## Seen in the wild

Real workflow files from public projects.

### microsoft/playwright - .github/workflows/tests_primary.yml

```yaml
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest]
        node-version: [22]
        shardIndex: [1, 2]
        shardTotal: [2]
        shardWeights: ['58:42']
```

Key lines: `shardIndex: [1, 2]`, `shardTotal: [2]`, `shardWeights: ['58:42']`.

Two shards, weighted 58:42, because the halves are not equally slow. fail-fast is off, so one shard failing cannot hide the other.

Source: [microsoft/playwright `.github/workflows/tests_primary.yml` lines 72-78](https://github.com/microsoft/playwright/blob/f3114b3e9f1aea9f0c123551ebf67ded08dddca2/.github/workflows/tests_primary.yml#L72-L78) at commit `f3114b3`.

### mastra-ai/mastra - .github/workflows/e2e-tests.yml

```yaml
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
```

Key line: `shard: [1, 2, 3, 4]`.

Four shards with fail-fast off. The job name reports which shard it is, so a red check tells you where to look.

Source: [mastra-ai/mastra `.github/workflows/e2e-tests.yml` lines 329-332](https://github.com/mastra-ai/mastra/blob/2a33bee4691f0027135ceae65aaf7e15aecac0c6/.github/workflows/e2e-tests.yml#L329-L332) at commit `2a33bee`.

### calcom/cal.diy - .github/workflows/e2e.yml

```yaml
      matrix:
        shard: [1, 2, 3, 4, 5, 6, 7, 8]
# ...
      - name: Run Tests
        run: yarn e2e --shard=${{ matrix.shard }}/${{ strategy.job-total }} --workers=4
```

Key lines: `shard: [1, 2, 3, 4, 5, 6, 7, 8]`, `run: yarn e2e --shard=${{ matrix.shard }}/${{ strategy.job-total }} --workers=4`.

One end-to-end suite split into eight parallel shards, the matrix index handed straight to Playwright's --shard flag.

Source: [calcom/cal.diy `.github/workflows/e2e.yml` lines 77-78,93-94](https://github.com/calcom/cal.diy/blob/f00434927386c9ecdcbd7e6c5f82d22044a245bc/.github/workflows/e2e.yml#L77-L94) at commit `f004349`, non-adjacent lines joined by `# ...`.

<a id="why-it-matters"></a>

## Why it matters

Sharding is a top wall-clock lever: it directly parallelizes your slowest test job. The honest tradeoff is that it trades runner-minutes for speed, the same tests still run, and each extra shard adds fixed setup overhead (checkout, install), so your bill goes up even as the critical path comes down. Stack it with dependency and build caching so per-shard setup doesn't eat the gains, and make sure your runners have enough concurrency to start every shard at once instead of queueing.

<a id="when-to-use"></a>

## When to use

**Use it when:** Any test job whose wall-clock is over ~5 minutes and whose framework supports sharding.

**Be careful when:** When the suite is short, or when per-job setup already dominates the runtime, past a point, adding shards stops moving wall-clock because the setup tax floors it. Fix caching first, then shard.

<a id="verify"></a>

## Verify on your repo

Hand this prompt to your coding agent to audit and fix this practice in your own repo:

Inspect this repo's .github/workflows for a long test job that runs the whole suite on a single runner with no `matrix` shard axis. Identify the slowest test job (wall-clock over ~5 minutes) and confirm its framework supports sharding (`--shard` for Playwright / Jest / Vitest, `--partition` for cargo-nextest, `pytest-split` for pytest). Add a `matrix` with a `shard` axis and pass the framework's native sharding flag so the suite splits across N parallel jobs, and make sure dependency and build caching is in place so per-shard setup does not eat the gains. Show me the diff and open a PR rather than applying it blindly.

Ground these changes in the upstream docs before you edit: https://playwright.dev/docs/test-sharding, https://github.com/jerry-git/pytest-split. If you cannot fetch them, say so rather than guessing, and cite what you used in the PR description.

Prefer to check by hand:

- Identify test jobs with wall-clock over ~5 minutes.
- Check whether the framework supports sharding (`--shard` for Playwright/Jest/Vitest, `--partition` for nextest, `pytest-split`).
- Confirm no `matrix` shard axis is configured, a single job running the whole suite is the flag.

<a id="more-best-practices"></a>

## More best practices for GitHub Actions

- [Build and test only what changed in GitHub Actions](https://starsling.dev/best-practices/github-actions/build-only-affected)
- [Cache dependencies in GitHub Actions](https://starsling.dev/best-practices/github-actions/cache-dependencies)
- [All CI best practices](https://starsling.dev/best-practices/github-actions)

<a id="faq"></a>

## FAQ

### Does sharding save money or just time?

Just time. Sharding lowers wall-clock (the critical path) but raises runner-minutes, because the same test work runs across more jobs and each shard repeats setup. It's the right lever when speed matters more than the bill, pair it with caching to keep the added overhead small.

### How many shards should I use?

Increase shards until the per-shard setup overhead (checkout, install, browser download) starts to dominate, that's the floor. Caching that setup lets you shard further before diminishing returns kick in.

### What is the shard command for Jest, Vitest, or pytest?

The matrix is the same for all of them, and every shard flag below is 1-based. What changes is whether you also need a merge step. Playwright and Vitest both do: pass `--reporter=blob` and each shard writes a partial blob report, so without a merge job you finish with N of them instead of one. Playwright merges with `npx playwright merge-reports`; Vitest merges with `npx vitest run --merge-reports`, a mode that reads the existing blobs rather than running the tests again. Watch out for one Vitest difference: it writes its blobs to a hidden directory, so its upload step also needs `include-hidden-files: true`, which `actions/upload-artifact` defaults to false. Jest takes the same flag (`npx jest --shard=${{ matrix.shard }}/${{ strategy.job-total }}`) and has no built-in merge, so each shard reports on its own. pytest has no built-in sharding: install `pytest-split`, run `pytest --splits ${{ strategy.job-total }} --group ${{ matrix.shard }}`, and commit a `.test_durations` file (`pytest --store-durations`) or the groups are balanced by assumed-average time and the shards skew. Rust uses cargo-nextest 0.9.127 or newer: `cargo nextest run --partition slice:${{ matrix.shard }}/${{ strategy.job-total }}`.

<a id="sources"></a>

## Sources

1. [Playwright - test sharding](https://playwright.dev/docs/test-sharding)
2. [GitHub Actions - matrix strategy](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/run-job-variations)
3. [Jest CLI - --shard](https://jestjs.io/docs/cli)
4. [Vitest CLI - --shard](https://vitest.dev/guide/cli)
5. [Vitest - sharding needs --reporter=blob and --merge-reports](https://vitest.dev/guide/improving-performance#sharding)
6. [pytest-split - --splits / --group](https://github.com/jerry-git/pytest-split)
7. [cargo-nextest - test partitioning](https://nexte.st/docs/ci-features/partitioning/)

<a id="get-started"></a>

## Get started

Run the suite in parallel, not in sequence.
