Monorepo CI/CD Testing Strategy: Impact Analysis, Sharding, and When to Run What

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:

  1. A dependency graph that maps which packages depend on which
  2. 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: 10m

Unit 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: 20m

For 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: 45m

E2E 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 HEAD

Most 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_HEAD

Global Changes That Affect Everything

Some files affect every package, and changes to them should trigger all tests:

  • Root tsconfig.json or babel.config.js
  • Root package.json dependencies (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 test

Test 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 }}.json

Playwright Sharding

npx playwright test --shard=1/4

Playwright 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@v4

Pipeline 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 }}/3

Optimizing 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/main HEAD, for accurate affected computation
  • Cache dependencies aggressively; install time should be under 30 seconds

Read more