Test Impact Analysis in Monorepos, 2026

Most monorepo CI pipelines still run the full test suite on every commit. Not because engineers don't know better, but because the tooling to do selective execution correctly — without silently skipping tests that should have run — has historically been fragile enough that the risk wasn't worth it. That calculus shifted in the last 18 months. Nx 17+, Turborepo 2, Bazel's remote execution improvements, and first-class Playwright shard support now make test impact analysis (TIA) a viable default, not a weekend experiment.

The technical problem is a three-way constraint: you need accurate change detection (which files actually affect which tests), stable execution graphs (so flaky tests don't corrupt the selection signal), and a CI integration that doesn't require rewriting your entire pipeline. Get any one of those wrong and TIA either misses regressions or saves you nothing.

By the end of this article you'll have a working mental model for wiring TIA into a monorepo CI pipeline, a concrete Playwright + GitHub Actions pattern with measured outcomes, and a clear-eyed view of where flaky tests actively sabotage selection accuracy — and what to do about it.

Manage All Your AI API Keys in One Place

Securely manage keys for 60+ AI providers in one encrypted vault instead of juggling them across apps.

Learn more

What Test Impact Analysis Actually Does at the Graph Level

Test impact analysis is a static or dynamic technique that maps source files to the test cases that exercise them, then uses that map to filter the execution set on each CI run to only the tests whose dependency graph intersects with the changed file set. The word "analysis" is doing real work here — TIA is not the same as path-glob filtering (if: files changed in 'packages/auth/**'), which is brittle the moment a shared utility moves. True TIA maintains a dependency graph: a change to libs/core/formatters.ts propagates to every package that imports it, including test suites three levels up the dependency tree.

In a modern test architecture, TIA sits between the VCS diff layer and the test executor. Tools like Nx compute this graph at the module level using TypeScript import analysis; Bazel does it at the build-target level using explicit deps declarations. Both approaches produce an affected-set that feeds directly into selective test execution — running only the affected tests rather than the full suite. The difference from naive path filtering is that the graph is computed, not authored, which means it stays accurate as the codebase evolves.

Wiring TIA into CI: Playwright, Nx, and GitHub Actions

The most common stack in 2026 for frontend-heavy monorepos is Playwright as the E2E runner, Nx for the dependency graph, and GitHub Actions for orchestration. The pattern below uses nx affected to compute the test targets, then shards Playwright across runners. On a 400-test suite across 12 packages, this dropped median CI wall time from 18 minutes to 4 on a typical feature-branch commit that touched 2 packages.

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  affected:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # required for nx affected base comparison
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - id: set-matrix
        run: |
          AFFECTED=$(npx nx show projects --affected --type=app --json)
          echo "matrix={\"project\":$(echo $AFFECTED)}" >> "$GITHUB_OUTPUT"

  e2e:
    needs: affected
    if: ${{ needs.affected.outputs.matrix != '{"project":[]}' }}
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJson(needs.affected.outputs.matrix) }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - run: npm ci
      - run: npx nx e2e ${{ matrix.project }} --shard=${{ strategy.job-index + 1 }}/${{ strategy.job-total }}

The fetch-depth: 0 is non-negotiable — Nx computes the affected set by diffing against the base branch, and a shallow clone breaks that comparison silently. The --type=app flag restricts the affected query to deployable applications rather than library packages; if your E2E tests live in library-level targets, adjust accordingly. The shard index mapping ensures Playwright distributes work evenly without a separate orchestration step.

For Playwright specifically, pair this with a project-scoped config so each app's E2E suite has its own playwright.config.ts with explicit testDir and baseURL. Sharing a single root config across all packages in the monorepo is the most common reason TIA savings evaporate — Playwright ends up discovering and running tests outside the affected project's directory.

// apps/checkout/playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3001',
    trace: 'on-first-retry',
  },
  retries: process.env.CI ? 1 : 0,   // single retry in CI only
  reporter: [['html'], ['github']],
});

One retry in CI is a pragmatic concession to infrastructure flakiness (port binding races, DNS resolution in ephemeral runners). Two or more retries start masking real failures and, more critically, pollute the TIA signal — a test that passes on retry still marks its owning package as "green," which means the next TIA run may exclude it even if the underlying issue persists. Keep retries at 1 and route persistent failures to a quarantine track, not a higher retry count.

Where Flaky Tests Corrupt the Selection Signal

Flaky tests are a TIA correctness problem, not just a developer-experience annoyance. When a flaky test fails, most pipelines mark the owning package as broken, which forces a full re-run of that package's suite on the next commit — even if no relevant files changed. Over time this erodes trust in the affected set, and teams quietly revert to running everything. The failure mode is organizational: engineers stop believing the selection is accurate, so they stop relying on it. The fix is to treat flake rate as a first-class metric gating TIA eligibility. A test with a flake rate above 2% over a 30-day window should be quarantined from the selection pool entirely until stabilized — but quarantine queues introduce their own re-entry risk if there's no automated review gate on promotion.

The second frustration is change-detection scope creep. Teams add nx affected but forget to account for environment-level dependencies: a change to a shared Docker base image, a Kafka schema update, or a Terraform module should invalidate a broader set of tests than the TypeScript import graph captures. For file-level selection in monorepos, the solution is to maintain an explicit "global invalidation" list — files whose change forces a full suite run regardless of the computed affected set. This list should be short (under 10 entries) and reviewed quarterly; if it grows, it's a signal that your dependency graph is under-specified.

Three Assumptions That Break TIA in Practice

Myth 1: TIA is safe to apply to all test layers equally. Unit and integration tests are good TIA candidates because their dependency graphs are well-bounded. E2E tests that exercise cross-package user journeys are not — a change to the auth package affects checkout flows even if the checkout package's files are untouched. Apply TIA aggressively at the unit layer, conservatively at E2E, and use explicit override rules for cross-cutting journeys. Myth 2: A passing affected run means unaffected tests are still valid. TIA skips tests, it doesn't verify them. If a shared library has a latent bug introduced two weeks ago, TIA will keep skipping the tests that would catch it until something in those packages changes. Complement TIA with a nightly full-suite run and track mean-time-to-detect separately for skipped vs. executed tests.

Myth 3: Higher parallelism always compensates for running the full suite. Throwing more runners at a 400-test suite can get wall time down, but it doesn't address the underlying signal-to-noise problem. A full run on 20 shards still executes tests that have zero relevance to the change, generates noise in test reports, and burns CI budget. TIA and parallelism are complementary, not substitutes — use TIA to shrink the execution set, then shard what remains. The 18-to-4-minute improvement cited earlier used both: TIA reduced the execution set by ~70%, and 4 Playwright shards handled the rest. Neither technique alone would have reached that outcome.

If you implement TIA with the pattern above, the next metric worth instrumenting is selection accuracy rate: the percentage of CI runs where the affected set correctly predicted which tests needed to run (validated against periodic full-suite runs). Anything below 95% is a signal your dependency graph has gaps. From there, the natural next problem is schema-level invalidation — changes to event contracts or API schemas that your import graph can't see.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles