Q39 of 48 · Cypress
How would you parallelise Cypress tests across CI machines without Cypress Cloud?
Short answer
Short answer: Generate a list of spec files, split it into N shards based on historical duration (or by file count as a fallback), and pass each shard to a separate runner via `--spec`. Most CI providers (GitHub Actions matrix, CircleCI parallelism, Buildkite) make this straightforward.
Detail
Cypress Cloud's --parallel flag uses the cloud to coordinate which spec each runner picks up. Without it, you manage the splitting yourself. The two approaches:
1. By file count (simple). List all specs, split into N equal-size chunks, run each on its own machine.
SPECS=$(find cypress/e2e -name '*.cy.ts' | sort)
TOTAL=$(echo "$SPECS" | wc -l)
PER_SHARD=$(( (TOTAL + N - 1) / N ))
START=$(( (SHARD_INDEX - 1) * PER_SHARD ))
SHARD_SPECS=$(echo "$SPECS" | tail -n +$((START+1)) | head -n $PER_SHARD | paste -sd,)
npx cypress run --spec "$SHARD_SPECS"
This is fine if specs have roughly uniform duration. If durations vary 10x, you'll have one shard finishing in 2 minutes and another in 20.
2. By historical duration (recommended). Record each spec's wall time on the previous run, then bin-pack the next run so each shard finishes around the same time. Tools like cypress-grep's plugin or hand-rolled shell scripts work. A typical implementation:
- After each successful CI run, post-process the JUnit XML to extract per-spec durations.
- Store them in a shared location (S3, gh-pages, the repo itself).
- Before the next run, read the file, sort specs by duration descending, and assign each to the currently-shortest shard (greedy bin-packing).
The bin-packing usually beats round-robin file assignment by 30-50% on heterogeneous suites.
3. CI provider integrations. GitHub Actions matrix strategy.matrix.shard: [1, 2, 3, 4] gives you the variable; pass it to your sharding script. CircleCI has circleci tests split --split-by=timings built in. Buildkite has buildkite-agent meta-data for coordinating durations.
4. Combine with retries and reports. Each shard publishes its own JUnit report; merge in a final job and surface the aggregate.
The trade-off vs Cypress Cloud: cloud auto-balances based on real-time progress (a slow runner picks up smaller specs); manual sharding pre-assigns and is less adaptive. But for a fixed-size suite where durations are stable, manual sharding is perfectly competitive — and it's free.
// EXAMPLE
.github/workflows/cypress.yml
name: cypress
on: [pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Compute shard specs (duration-balanced)
id: split
run: |
# Pull historical timings from previous run
curl -s -o timings.json https://ci-artifacts.example/timings/main.json
# Run a small node script that bin-packs by duration
SPECS=$(node scripts/shard.js \
--shard ${{ matrix.shard }} \
--total 8 \
--timings timings.json)
echo "specs=$SPECS" >> $GITHUB_OUTPUT
- run: npx cypress run --spec "${{ steps.split.outputs.specs }}"
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: report-shard-${{ matrix.shard }}
path: cypress/results/