Monorepo CI/CD Testing Strategy: Impact Analysis, Sharding, and When to Run What
The most common CI problem in monorepos is running too many tests on every commit. A change to a CSS file triggers a full backend test suite. An API schema update re-runs E2E tests for three unrelated apps. Over time, CI becomes a 45-minute wall that developers learn to ignore.
A good monorepo CI strategy answers three questions: Which tests need to run? In what order? On what machine resources? This guide covers impact analysis, sharding strategies, and how to structure your pipeline around test types.
The Core Principle: Test What Changed
The first goal is to never run a test that can't possibly be affected by the current change. This requires two things:
- A dependency graph that maps which packages depend on which
- A change detection mechanism that uses that graph to compute affected targets
Every major monorepo tool provides this:
| Tool | Change detection command |
|---|---|
| Nx | nx affected --target=test |
| Turborepo | turbo test --filter=[origin/main] |
| pnpm workspaces | pnpm --filter "...[origin/main]" run test |
| Bazel | bazel test $(bazel query 'rdeps(//..., set($(git diff --name-only main)))' 2>/dev/null) |
| Lerna | lerna run test --since main |
The principle is the same: compare the current branch to the merge base, find changed packages, traverse the dependency graph to find affected packages, run tests only for those.
Test Types and When to Run Them
Not all tests should run on every event. Match test type to CI trigger:
Unit Tests — Every Commit
Unit tests are fast (milliseconds per test), isolated (no external dependencies), and should run on every push and pull request. They're the first line of defence:
# Runs on every push and PR
unit-tests:
trigger: [push, pull_request]
scope: affected
runner: ubuntu-latest
timeout: 10mUnit tests should never need a database, HTTP service, or external API. If a unit test requires a mock of an external service, that's fine — the mock is part of the test.
Integration Tests — Every PR
Integration tests verify that multiple components work together: service + database, service + cache, service + message queue. They're slower (seconds per test) and need infrastructure:
# Runs on PRs, not on every push to feature branches
integration-tests:
trigger: [pull_request]
scope: affected
services: [postgres, redis]
timeout: 20mFor PRs against main, run integration tests for all affected packages. For intermediate commits on a feature branch, you might only run unit tests to keep feedback fast.
E2E Tests — Before Merge to Main
End-to-end tests exercise the full stack — browser, API, database. They're the slowest (minutes per test) and most brittle. Running them on every PR creates noise:
# Runs on PRs that target main, and on main itself
e2e-tests:
trigger:
- pull_request:
branches: [main]
- push:
branches: [main]
scope: all # E2E usually can't be scoped by package
timeout: 45mE2E tests rarely map cleanly to individual packages — a change to an auth library might affect E2E tests for every app. Consider running all E2E tests pre-merge unless your tooling can accurately determine scope.
Impact Analysis in Practice
Calculating the Merge Base
Change detection compares the current commit against the merge base — the point where the branch diverged from the base branch. This is critical: comparing against main's current HEAD includes all changes since you branched, which includes other people's merged work and can over-trigger tests.
# Find merge base
MERGE_BASE=$(git merge-base origin/main HEAD)
<span class="hljs-comment"># List changed files since merge base
git diff --name-only <span class="hljs-variable">$MERGE_BASE HEADMost CI tools provide this automatically:
# GitHub Actions — nrwl/nx-set-shas computes correct base/head
- uses: nrwl/nx-set-shas@v4
# This sets NX_BASE and NX_HEAD correctly
- run: npx nx affected --target=test --base=$NX_BASE --head=$NX_HEADGlobal Changes That Affect Everything
Some files affect every package, and changes to them should trigger all tests:
- Root
tsconfig.jsonorbabel.config.js - Root
package.jsondependencies (especially test runner versions) - Shared configuration in
packages/config - CI workflow files themselves
Handle this with a fallback:
# In GitHub Actions
- name: Check for global changes
id: global-changes
run: |
CHANGED=$(git diff --name-only origin/main HEAD)
if echo "$CHANGED" | grep -qE "(tsconfig|babel\.config|packages/config)"; then
echo "run_all=true" >> "$GITHUB_OUTPUT"
fi
- name: Run affected tests
if: steps.global-changes.outputs.run_all != 'true'
run: pnpm --filter "...[origin/main]" run test
- name: Run all tests (global change detected)
if: steps.global-changes.outputs.run_all == 'true'
run: pnpm -r run testTest Sharding
When a single package has hundreds or thousands of tests, running them all on one machine takes too long. Sharding splits the test suite across multiple parallel workers:
Jest Sharding
# Split into 4 shards, run shard 1
jest --shard=1/4
<span class="hljs-comment"># Run all shards in parallel (GitHub Actions matrix)
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: jest --shard=<span class="hljs-variable">${{ matrix.shard }}/4# Full GitHub Actions sharding example
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: jest --shard=${{ matrix.shard }}/4 --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.shard }}
path: coverage/Vitest Sharding
vitest run --shard=1/4- run: vitest run --shard=${{ matrix.shard }}/4 --reporter=json --outputFile=results-${{ matrix.shard }}.jsonPlaywright Sharding
npx playwright test --shard=1/4Playwright also supports parallel workers within a single machine with --workers.
Coverage Merging After Shards
When shards each produce a coverage report, merge them before uploading:
merge-coverage:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: coverage-*
merge-multiple: true
path: coverage/
- run: npx nyc merge coverage coverage/merged.json
- run: npx nyc report --reporter=lcov --temp-dir=coverage
- uses: codecov/codecov-action@v4Pipeline Architecture
A complete pipeline that applies these principles:
name: CI
on:
push:
branches: ['**']
pull_request:
branches: [main]
jobs:
# Step 1: Determine scope
determine-scope:
runs-on: ubuntu-latest
outputs:
affected-packages: ${{ steps.affected.outputs.packages }}
run-all: ${{ steps.global.outputs.run_all }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: global
run: |
CHANGED=$(git diff --name-only origin/${{ github.base_ref || 'main' }} HEAD)
if echo "$CHANGED" | grep -qE "^(tsconfig|package\.json|packages/config)"; then
echo "run_all=true" >> "$GITHUB_OUTPUT"
fi
# Step 2: Unit tests (always fast)
unit-tests:
needs: determine-scope
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: pnpm install --frozen-lockfile
- name: Run affected unit tests
if: needs.determine-scope.outputs.run-all != 'true'
run: pnpm --filter "...[origin/${{ github.base_ref || 'main' }}]" run test:unit
- name: Run all unit tests
if: needs.determine-scope.outputs.run-all == 'true'
run: pnpm -r run test:unit
# Step 3: Integration tests (on PRs)
integration-tests:
needs: unit-tests
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
options: --health-cmd "redis-cli ping" --health-interval 10s
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: pnpm install --frozen-lockfile
- run: pnpm --filter "...[origin/main]" run test:integration
env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/test
REDIS_URL: redis://localhost:6379
# Step 4: E2E tests (only on PRs to main)
e2e-tests:
needs: integration-tests
if: github.base_ref == 'main'
strategy:
matrix:
shard: [1, 2, 3]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: npx playwright test --shard=${{ matrix.shard }}/3Optimizing for Feedback Speed
A few concrete rules for keeping CI fast:
Put fastest tests first. Unit tests should run in under 5 minutes. If they don't, that's a problem to fix before adding more tests.
Fail fast on obvious errors. Run lint and type checks before tests — they're faster and catch syntax errors that would waste time in the test runner.
Parallelize independent jobs, not dependent ones. Unit tests for packages/ui and packages/api can run in parallel. Integration tests that depend on those builds should run after.
Set aggressive timeouts. A test that hangs should fail within 30 seconds, not block a CI runner for 10 minutes.
Cache node_modules. pnpm's cache action or actions/setup-node with cache: 'pnpm' cuts install time from 3 minutes to 10 seconds on cache hits.
When All Tests Aren't Enough
Impact analysis tells you which packages are affected. But it can't protect against:
- Shared runtime behaviour (a change to shared middleware affects all API routes)
- Data migrations (schema changes affect every consumer of that schema)
- API contract changes (a changed response shape breaks every client)
For these scenarios, contract testing (Pact) and integration tests at the API gateway level supplement package-level impact analysis.
Key Takeaways
- Run affected tests only: never run a test that can't be affected by the current change
- Match test type to CI trigger: unit on every push, integration on PRs, E2E before merging to main
- Shard large test suites across matrix jobs; merge coverage reports afterward
- Define "global changes" explicitly — files that affect every package should trigger all tests
- Use merge base comparison, not
origin/mainHEAD, for accurate affected computation - Cache dependencies aggressively; install time should be under 30 seconds