Test Impact Analysis: Playwright Monorepo
Most Playwright monorepo setups run the full test suite on every push. At 50 packages that's survivable; at 150 it's a 25-minute CI wall that engineers learn to ignore. The suite doesn't get slower because teams are careless — it gets slower because the default configuration has no concept of dependency topology. Every change triggers everything, regardless of blast radius.
Test impact analysis (TIA) fixes this by mapping source changes to the tests that actually exercise them, then running only that subset. The concept is older than Playwright — Microsoft Research published on it in 2014 — but applying it in a JavaScript monorepo with Playwright-native tooling in 2026 requires threading together several moving parts: a dependency graph, a file-to-test mapping, and a CI layer that respects the result.
By the end of this article you'll have a concrete implementation pattern: how to build the change-to-test map, how to feed it into Playwright's --grep or project filtering, and where the approach breaks down so you don't discover that in production CI.
Securely manage keys for 60+ AI providers in one encrypted vault instead of juggling them across apps.
What Test Impact Analysis Actually Does in a Playwright Monorepo
TIA is not a test runner feature — it's a pre-selection layer that sits between your VCS diff and your test executor. Given a set of changed files, it walks a dependency graph (package-level or module-level) to produce a list of affected packages, then maps those packages to test files. The test runner receives a filtered input and never loads the rest. In a Playwright context, "affected tests" typically means a subset of playwright.config.ts projects, a glob passed to testMatch, or a --grep tag expression.
Where TIA fits in a modern test architecture: it belongs at the CI trigger layer, upstream of sharding. It's complementary to parallelizing execution across GitHub Actions matrix jobs — you select first, then shard the selected subset. Skipping selection and going straight to sharding just distributes the waste across more runners. TIA also interacts with flaky-test quarantine: a test excluded by TIA still needs to run on a schedule, or its quarantine status silently rots. That coupling is worth designing explicitly.
Building the Change-to-Test Map for Selective Test Execution
The most reliable starting point in a JavaScript monorepo is the workspace dependency graph already encoded in your package manager. With pnpm or Turborepo you can extract it without writing a custom parser. The following uses Turborepo's --affected flag (introduced in Turbo 1.10) to emit changed package names, then maps them to Playwright projects:
# .github/workflows/playwright-affected.yml
jobs:
affected-tests:
runs-on: ubuntu-latest
outputs:
projects: ${{ steps.map.outputs.projects }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for accurate diff
- name: Get affected packages
id: affected
run: |
AFFECTED=$(pnpm turbo run test --dry=json --filter='...[origin/main]' \
| jq -r '[.tasks[].package] | unique | @json')
echo "packages=$AFFECTED" >> $GITHUB_OUTPUT
- name: Map packages to Playwright projects
id: map
run: node scripts/map-to-playwright-projects.js '${{ steps.affected.outputs.packages }}'
map-to-playwright-projects.js reads a static JSON manifest — maintained alongside playwright.config.ts — that declares which Playwright project name owns which workspace package. The script emits a comma-separated string consumed by the next job. This is intentionally dumb: a static manifest is easier to audit in a PR than a dynamic heuristic.
// scripts/map-to-playwright-projects.js
const manifest = require('../test-project-manifest.json');
const affected = JSON.parse(process.argv[2]);
const projects = affected
.flatMap(pkg => manifest[pkg] ?? [])
.filter((v, i, a) => a.indexOf(v) === i);
const output = projects.length ? projects.join(',') : '__none__';
require('fs').appendFileSync(process.env.GITHUB_OUTPUT, `projects=${output}\n`);
// playwright.config.ts — conditional project activation
import { defineConfig } from '@playwright/test';
const active = process.env.PW_PROJECTS?.split(',') ?? [];
const allProjects = [
{ name: 'checkout', testDir: './packages/checkout/e2e' },
{ name: 'auth', testDir: './packages/auth/e2e' },
{ name: 'catalog', testDir: './packages/catalog/e2e' },
];
export default defineConfig({
projects: active.length
? allProjects.filter(p => active.includes(p.name))
: allProjects,
});
With this wiring, a PR that touches only packages/auth runs the auth Playwright project — nothing else. On a 12-package monorepo with ~900 Playwright specs, a single-package change dropped median CI time from 18 minutes to 4. Full-suite runs remain on the nightly schedule and on merge to main, so coverage gaps don't accumulate silently. For deeper background on the tradeoffs of selecting only affected files in monorepo CI, the dependency-graph approach described there maps cleanly onto this Playwright-project model.
Handling transitive dependencies
The naive manifest approach breaks when a shared utility package changes — every consumer is affected but the manifest only lists direct owners. Turbo's --filter='...[origin/main]' syntax already resolves transitive dependents, so the affected package list is correct; your manifest just needs entries for shared packages pointing to all projects that depend on them. A __shared__ sentinel that maps to every project is a valid escape hatch, but use it sparingly — it's a full-suite trigger in disguise.
Where Selective Test Execution Breaks Down in Practice
Stale manifests are the most common failure mode. Engineers add a new Playwright project, forget to update test-project-manifest.json, and the project never runs on PRs. This surfaces weeks later as a regression that "should have been caught." The fix is a CI lint step that diffs the manifest against the declared projects in playwright.config.ts and fails fast on mismatch — a 10-line Node script that pays for itself the first time it fires. The deeper issue is organizational: if the manifest lives in a different PR from the new package, the gap is invisible until it isn't.
The second pitfall is treating TIA as a replacement for scheduled full runs. TIA is a PR-time optimization. Tests excluded from a PR run because their package wasn't touched can still fail due to environment drift, third-party API changes, or data-dependency rot. Teams that disable nightly full-suite runs to save runner costs discover this the hard way. Flaky tests are a related trap: a test that's intermittently failing but excluded by TIA won't appear in your quarantine queue, so its status goes unreviewed. Schedule full runs; treat TIA as additive, not subtractive.
Myths About Monorepo Test Selection That Slow Teams Down
"We need a dedicated TIA platform to do this properly." Commercial TIA tools (Launchable, BuildPulse, Trunk) add ML-based selection on top of historical test results. They're worth evaluating at scale — 10,000+ specs, multiple languages — but they're not the entry point. The Turbo + static manifest approach described above is production-grade for most JavaScript monorepos and adds zero vendor dependency. Start with the simple model; graduate to ML-assisted selection when you have the data volume to make the model meaningful. For a broader view of TIA tooling choices in 2026, the tradeoffs between static graph analysis and ML-based selection are worth reading before committing to a platform.
"Playwright's built-in sharding handles this." Sharding splits a fixed test list across workers — it doesn't reduce the list. Running 900 specs across 4 shards gives you 225 specs per shard, but you're still running all 900. TIA and sharding are orthogonal: use TIA to shrink the list, then shard the remainder if it's still large enough to benefit. Conflating the two leads teams to add more runners instead of questioning why they're running tests that can't possibly be affected by the change in front of them.
If you implement the Turbo + manifest pattern, the next metric worth tracking is selection recall: what percentage of PR-level regressions were caught by the affected subset versus only by the nightly full run. A recall below 90% signals that your dependency graph or manifest has gaps worth closing. Instrument it by tagging CI runs with trigger=pr-tia vs trigger=nightly and correlating against first-failure timestamps in your test reporting layer.
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.