Monorepo CI: Run Only Affected Tests
A 40-package monorepo where every commit triggers the full test suite is a solved problem that most teams refuse to solve. The tooling has been production-ready since Nx 15 and Turborepo 1.x, GitHub Actions added native path filters years ago, and yet engineering orgs still watch 22-minute pipelines crawl through packages that haven't changed since the last sprint. The cost isn't just CI minutes — it's the feedback loop that makes developers stop waiting for green and start merging on yellow.
The technical problem is deceptively specific: given a set of changed files in a commit or pull request, compute the minimal closure of test targets that must run to maintain confidence — and do it reliably enough that skipping a test never means missing a regression. That closure includes direct owners of changed files and transitive dependents, and it has to survive dependency graph drift as the repo evolves.
By the end of this article you'll have a working mental model and concrete YAML + script patterns for wiring affected-test selection into GitHub Actions or Jenkins, handling the edge cases that break naive implementations, and knowing when to trust the graph versus when to run everything.
Smarter API Test Automation — Python, Behave, VS Code, AI with GitHub Copilot & CI/CD Pipelines. Complete in a Weekend!
Dependency Graphs, Affected Sets, and What "Testing Files" Actually Means
Affected-test selection is not glob-based path filtering. Path filtering — on: push: paths: ['packages/auth/**'] — tells CI to run a hardcoded job when a hardcoded path changes. It breaks the moment a downstream package imports from packages/auth without the filter knowing about it. Affected-test selection, by contrast, builds or queries a dependency graph and walks it: changed node → all nodes that depend on it (directly or transitively) → the union of their test targets. Nx calls this nx affected; Turborepo calls it turbo run test --filter=...[HEAD^1]; Bazel calls it bazel query 'rdeps(...)'. The mechanism differs; the model is identical.
In a modern test architecture this sits at the CI entry point, upstream of everything else — before test sharding, before container spin-up, before any selective test execution strategy can be applied. The output is a list of targets, not a list of files. That distinction matters: a target bundles its own runner config, environment variables, and retry policy. Treating it as a file list is where most homegrown implementations go wrong.
Wiring Changed-File Detection into CI: YAML, Scripts, and Self-Healing Tests
The fastest path for a GitHub Actions shop is combining tj-actions/changed-files with your graph tool's affected command. The changed-files action outputs a JSON array of touched paths; you pipe that into Nx or Turborepo and let the graph do the rest.
# .github/workflows/ci.yml
jobs:
affected:
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.nx.outputs.targets }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history required for base-SHA diffing
- uses: tj-actions/changed-files@v44
id: changes
with:
json: true
- name: Compute affected targets
id: nx
run: |
TARGETS=$(npx nx show projects --affected \
--base=origin/main --head=HEAD \
--json | jq -c '[.[] | . + ":test"]')
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
test:
needs: affected
if: ${{ needs.affected.outputs.targets != '[]' }}
strategy:
matrix:
target: ${{ fromJson(needs.affected.outputs.targets) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx nx run ${{ matrix.target }}
fetch-depth: 0 is non-negotiable. Shallow clones break base-SHA resolution and silently fall back to running everything — the worst failure mode because it looks like it's working. A team at a fintech I've seen drop pipeline duration from 18 minutes to 4 minutes on a 28-package repo made exactly this fix after two weeks of wondering why affected detection seemed unreliable.
Python monorepos with Pytest
For Python repos without a build graph tool, you can approximate affected detection by combining git diff with import graph analysis via importlab or a lightweight custom script:
# scripts/affected_pytest.py
import subprocess, json, sys
from pathlib import Path
changed = subprocess.check_output(
["git", "diff", "--name-only", "origin/main...HEAD"]
).decode().splitlines()
changed_modules = {
Path(f).stem for f in changed if f.endswith(".py")
}
# collect test files that import any changed module
affected_tests = []
for test_file in Path("tests").rglob("test_*.py"):
source = test_file.read_text()
if any(mod in source for mod in changed_modules):
affected_tests.append(str(test_file))
print(json.dumps(affected_tests))
This is a heuristic, not a graph walk — it misses transitive imports. Use it only when Nx/Bazel is off the table and you understand the false-negative risk. For anything with shared utility layers, the string-match approach will under-select and give you false confidence.
Self-healing tests in CI
Affected selection interacts badly with flaky tests. If a flaky test lives in an unaffected package and never runs, it never flakes — until the day that package is touched and it suddenly fails intermittently, making the affected run look unreliable. The fix is a nightly full-suite run that feeds a flakiness database, combined with a retry policy scoped to known-flaky targets. Do not let affected selection become a reason to never audit your full suite. Teams that skip the nightly run eventually accumulate a backlog of silent failures that surface at the worst possible time — usually during a release freeze. If you're managing flaky test re-entry, the pattern described in quarantine queues and unreviewed re-entry is directly relevant here.
Three Mistakes Senior Engineers Still Make with Affected Test Pipelines
Trusting the graph without versioning it. Nx and Turborepo infer the dependency graph from package.json imports and config files. When a developer adds a new internal import without updating the project config, the graph silently misses the edge. The affected set is now wrong — not broken-wrong, but quietly-wrong. The fix is to run nx graph as a CI check on every PR that touches project configuration and diff the output. It's a 30-second job that prevents weeks of confusion. Similarly, schema drift in shared contracts can invalidate your graph assumptions when packages evolve their interfaces without coordinating downstream consumers.
Using --base set to a merge-base that isn't stable. On trunk-based development repos, origin/main moves during a long-running PR. If your base SHA resolves differently between the "compute affected" step and the "run tests" step — because another PR merged in between — you get a different affected set than you computed. Pin the base SHA at workflow start with git merge-base HEAD origin/main and pass it explicitly. The second mistake is conflating "no affected targets" with "safe to merge." A change to a root-level config file (tsconfig.base.json, jest.config.js, pyproject.toml) should trigger a full run, not an empty affected set. Encode this as an escape-hatch rule: if any root config changes, bypass affected selection entirely.
What Most Teams Get Wrong About Selective Testing in Monorepos
Myth: affected testing is only for JavaScript monorepos. Nx and Turborepo are JS-native, but the pattern applies to any language with a queryable dependency graph. Bazel handles Java, Go, Python, and C++ with bazel query. Pants 2.x covers Python and Java. Even a Makefile-based repo can approximate it with git diff and explicit dependency declarations. The tool is language-specific; the model is universal. A second myth worth killing: that 100% coverage on the affected set is sufficient. Coverage measures which lines execute, not which behaviors are exercised. A package with 95% line coverage and zero contract tests can still break a downstream consumer silently — which is why Pact-based contract testing belongs in the affected pipeline for any package that exposes a public API surface.
Myth: selective testing means you can stop thinking about asynchronous testing tools. In event-driven architectures — Kafka, Pulsar, SNS — a change to a producer package may not have a synchronous test dependency on the consumer package, so the graph won't include it in the affected set. But the behavioral contract still exists. Asynchronous testing tools like Pact's message support or testcontainers-based Kafka harnesses need to be explicitly wired into the dependency graph as test-time edges, or you'll ship producer changes that silently break consumers. The affected graph only knows what you tell it.
If you implement affected-test selection and your pipeline duration drops significantly, the next metric worth tracking is mean-time-to-detect on regressions that slip through — specifically in packages that are rarely touched and therefore rarely in the affected set. A monthly audit comparing your affected-run coverage against the nightly full-suite results will surface graph gaps before they become incidents. For teams managing AI-generated test artifacts alongside this pipeline, LLM-generated test reports can help surface coverage blind spots that static graph analysis misses.
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.