How does it work?
A monorepo testing strategy built on affected-test selection works in three layers. A dependency graph records which packages depend on which. On every pull request, CI uses that graph to run only the tests a change could affect. Slower integration and e2e suites run at boundaries and on merge, scoped by the same graph. A periodic full run then checks that the graph itself is still accurate. Each piece is covered in its own section below: how selection computes the affected set, how to keep the graph trustworthy, where each test layer belongs, how integration and e2e tests run, and how flakiness, caching and sharding fit in.
The mechanism is deterministic. Changed files map to owning packages. Owning packages expand through reverse dependencies to every package that could observe the change. Only that set is built and tested. Anything the graph cannot attribute to a package, such as root lockfiles or CI definitions, triggers the full suite instead.
Who should use it?
This approach suits teams that share one repository across many packages or services and whose CI time has become a bottleneck. Typical signs:
- Several teams commit to the same repo. Most PRs touch one team's packages, yet every PR waits for everyone's tests.
- A platform or developer-productivity owner exists. Someone has to maintain the graph tooling, the global-inputs list, the cache and the sharding. Without a named owner, the graph drifts and selection starts producing false negatives.
- Package boundaries are declared in manifests or build files. Teams that already declare dependencies per package can derive the graph from those files. Teams whose code reaches across directories freely must clean that up first.
- Services talk to each other across defined interfaces. Contract tests and journey-scoped e2e tests need clear boundaries to attach to.
A single team working on one application in a monorepo-shaped layout usually gets little from it. The graph bookkeeping costs about the same, and the savings are small.
When should it be used?
Use affected-test selection when all of the following hold:
- The full suite is slow enough to block review. Engineers wait on CI long enough that they context-switch, or PRs queue behind each other.
- Most changes are local. The typical PR touches leaf or mid-graph packages, not the handful of libraries that everything imports. You can check this from history by mapping recent PRs' changed files to packages and counting how many dependents each change would select.
- You can enforce graph accuracy in CI. Undeclared imports and cross-package relative paths fail the build, as described below.
- You can afford a full-run safety net. Nightly or on every merge to main, with an owner for failures.
- PR volume is high. The per-PR saving multiplies across many PRs per day, which justifies the ongoing maintenance.
Adopt it incrementally. Start with unit-test selection on PRs while keeping a full run on merge. Add scoped integration runs next. Scope e2e by affected journeys only once the graph has proven reliable, meaning the full run rarely finds failures that selection missed.
How affected-test selection works
A graph-driven monorepo testing strategy rests on one idea: a change can only break code that depends on it. Selection has four steps. First, list the files changed between the PR head and a base commit, for example with git diff --name-only <base>...HEAD. Second, map each file to the package that owns it, usually by finding the nearest directory containing a package manifest or build file. Third, walk the reverse dependency graph from those packages to collect every direct and transitive dependent. Fourth, run tests only for that set. A change to a shared logging library fans out widely. A change to a leaf service touches only itself.
The base commit determines what counts as "changed."
- Merge-base (
git merge-base origin/main HEAD) isolates the PR's own changes. Results are stable and easy to reason about. However, if main broke after the branch diverged, the PR will not detect it. - Last green main build diffs against the most recent commit where the full suite passed. The affected set grows to include anything that landed on main since then, including untested breakage. This costs more per run but is safer when main is frequently red or merge queues are absent.
Teams benefit most when their full suite is too slow to run on every PR. For a small repo with a fast suite, the graph bookkeeping adds risk and complexity without saving much time.
#!/usr/bin/env bash
set -euo pipefail
BASE="${1:-$(git merge-base origin/main HEAD)}"
DEPS_FILE="deps.txt"
affected=$(git diff --name-only "$BASE" HEAD \
| awk -F/ '$1 == "packages" && NF > 2 { print $2 }' | sort -u)
while :; do
set_str=" $(echo $affected) " # space-delimited for exact-word matching
dependents=$(awk -v set="$set_str" '{
for (i = 2; i <= NF; i++) if (index(set, " " $i " ")) { print $1; break }
}' "$DEPS_FILE")
merged=$(printf '%s\n%s\n' "$affected" "$dependents" | sed '/^$/d' | sort -u)
[ "$merged" = "$affected" ] && break
affected="$merged"
done
if [ -z "$affected" ]; then
echo "No affected packages; skipping tests."
exit 0
fi
for pkg in $affected; do
echo "Testing $pkg"
(cd "packages/$pkg" && npm test)
done
A tool-agnostic bash script that diffs against a base commit (merge-base by default, or a last-green main SHA), maps changed files to packages, expands the set through reverse dependencies to a fixed point, and runs tests only for the affected packages.
Making the dependency graph trustworthy
Affected-test selection only skips work safely if the dependency graph matches what the code actually does. If package A reads a file from package B without declaring the dependency, a change to B will not select A's tests. CI goes green and the regression ships. That is a false negative, and selection hides it, because nobody sees which tests were skipped.
Two rules keep the graph honest. First, every package declares its dependencies in its manifest, and the build fails when code imports something that is not declared. Second, ban relative imports that cross a package boundary, such as ../../billing/src/rates. Those paths bypass the manifest completely. A boundary lint rule should reject any import that resolves outside the importing package's root unless it goes through a declared package name. Enforce both in CI. A convention that is only documented will drift.
Some files affect everything but belong to no package. List them explicitly as global inputs. A change to any of them triggers a full run:
| Input | Why it is global | Example pattern |
|---|---|---|
| Root lockfile | Can change transitive versions for every package | package-lock.json, go.sum at root |
| Shared build config | Alters compilation or bundling everywhere | tsconfig.base.json, root build files |
| CI definitions | Changes how tests run, not just which | .ci/** |
| Codegen inputs | Generated code lands in many packages | schemas/**/*.proto |
| Shared test fixtures | Consumed implicitly by many suites | testdata/shared/** |
Default to treating unclassified root-level files as global. An unnecessary full run costs you time. A missed one costs you correctness.
#!/usr/bin/env bash
set -euo pipefail
BASE_REF="${1:-origin/main}"
is_global() {
case "$1" in
package-lock.json|go.sum) return 0 ;; # root lockfiles
tsconfig.base.json) return 0 ;; # shared build config
.ci/*) return 0 ;; # CI definitions
schemas/*.proto) return 0 ;; # codegen inputs
testdata/shared/*) return 0 ;; # shared test fixtures
esac
return 1
}
is_known_inert() {
case "$1" in
README.md|CONTRIBUTING.md|LICENSE) return 0 ;;
esac
return 1
}
mode="affected"
while IFS= read -r file; do
[[ -z "$file" ]] && continue
if is_global "$file"; then
echo "global input changed: $file" >&2; mode="full"; break
fi
# Unclassified root-level files default to global: a wasted run beats a missed one.
if [[ "$file" != */* ]] && ! is_known_inert "$file"; then
echo "unclassified root file: $file" >&2; mode="full"; break
fi
done < <(git diff --name-only "$BASE_REF"...HEAD)
echo "$mode"
A CI pre-step that checks changed files against an explicit global-inputs list and treats any unclassified root-level file as global, printing "full" or "affected" for the test runner to act on.
Reshaping the test pyramid for a monorepo
Affected-test selection changes the economics of each layer. A unit test that only runs when its package changes is almost free. An e2e test that every change touches runs on nearly every pull request. That asymmetry should decide where a test lives.
| Layer | Scope | Trigger | Typical runtime | Owner |
|---|---|---|---|---|
| Unit | One package, no I/O | Package or its dependencies changed | Seconds | Package team |
| Contract / integration | One service boundary | Either side of the contract changed | Minutes | Provider and consumer teams |
| E2E | A critical user journey | Any service on the journey changed, plus scheduled runs | Tens of minutes | Platform or QA |
Which layer a new test belongs in
- If the behaviour can be verified inside one package, write a unit test. The graph scopes it precisely.
- If it depends on another service's request shape or response semantics, write a contract or integration test at that boundary.
- Write an e2e test only when a failure would break a journey users depend on, such as signup, checkout or login, and no lower layer can detect it.
Contract tests versus e2e
Both catch cross-service breakage. Contract tests fail fast and point to one boundary, but someone has to maintain them. Each contract needs a version, a publishing step and an agreement about who updates it when the provider changes. In a monorepo, provider and consumer can change in the same commit, which removes most of the version drift. The ownership work remains.
E2E tests skip that coordination, but you pay for it in runtime, flakiness and vague failures. Use contract tests for each boundary and keep e2e for the journeys themselves.
Integration tests with real dependencies, and where e2e runs
Integration tests are where the database driver, the ORM's generated SQL and the queue client's retry behaviour meet real infrastructure. Run them against real Postgres, Redis or a message broker in containers started per CI job, so no two jobs share state and a failed run leaves nothing behind for the next one.
Container startup is the main cost, and it compounds across hundreds of affected packages. Three habits keep it bounded:
- Shared base images. Build one image per dependency, with extensions and config baked in, and pull it from a registry the CI runners cache. Never install packages at job start.
- Migrations once per job. Apply the schema when the container is healthy, before any test runs. Do not apply it per test file. Migrations are slow, and they don't change mid-job.
- Per-test isolation. Wrap each test in a transaction and roll it back in teardown. This is cheap and leaves no residue. For code that commits on its own, or for queues, give each test a unique schema, topic or key prefix derived from its ID.
Mocks cost almost nothing to run, but they encode your assumptions about the dependency rather than its behaviour. Constraint violations, lock contention and serialization quirks pass silently against a mock and fail in production.
E2E belongs on merge to main or on a nightly schedule, against a deployed staging environment or a fully composed stack. Use the affected-service set from the graph to choose which user journeys to run. A change to billing triggers the checkout and invoicing flows, not the whole suite.
The cost of this placement is that some regressions land on main before anything catches them. That only works if the team agrees in advance who acts on a red post-merge run, and whether the default response is to revert or fix forward within a fixed window.
import hashlib, os, pathlib
import psycopg
import pytest
DB_URL = os.environ.get("DATABASE_URL", "postgresql://test:test@localhost:5432/app_test")
@pytest.fixture(scope="session")
def migrated_db():
# Once per job: the schema can't change mid-run, so never re-apply it per test.
with psycopg.connect(DB_URL, autocommit=True) as conn:
conn.execute(pathlib.Path("migrations/schema.sql").read_text())
return DB_URL
@pytest.fixture
def db(migrated_db):
# Autocommit is off, so the first statement opens a transaction we never commit.
conn = psycopg.connect(migrated_db)
try:
yield conn
finally:
conn.rollback() # discard everything the test wrote; no cleanup scripts
conn.close()
@pytest.fixture
def ns(request):
# For code that commits on its own, or for queues: a stable unique prefix
# (schema, topic or key) derived from the test ID.
return "t_" + hashlib.sha1(request.node.nodeid.encode()).hexdigest()[:10]
def test_duplicate_email_rejected(db):
db.execute("INSERT INTO users (email) VALUES (%s)", ("a@example.com",))
with pytest.raises(psycopg.errors.UniqueViolation):
db.execute("INSERT INTO users (email) VALUES (%s)", ("a@example.com",))
A pytest and psycopg setup that applies migrations once per CI job and wraps each test in a rolled-back transaction, with a per-test namespace fixture for code that commits or uses queues.
Flaky tests, caching and sharding
A post-merge run is only useful if red means broken, so flakiness has to be measured. The clearest signal is a test that fails, is retried on the same commit, and passes. Record every retry-then-pass per test. One occurrence is noise, but repeats across unrelated commits mean the test is flaky. Quarantine it so it still runs and reports but no longer blocks merges, and give the quarantine entry an owner and an expiry date. When the date passes, the test either rejoins the blocking set or is deleted on purpose. Without the deadline, quarantine fills up with disabled coverage nobody remembers.
Retries must stay visible. If a PR passed only on retry, show that on the PR. A silent retry can hide a race condition the change itself introduced.
Caching treats a test result as an output keyed on a hash of its inputs: sources, dependencies, fixtures, toolchain and declared configuration. A cached pass is only safe to reuse if nothing outside that key can change the outcome.
| Trait | Hermetic | Non-hermetic | Safe to cache? |
|---|---|---|---|
| Time | Injected or frozen clock | Reads wall clock | Only if hermetic |
| Network | None, or local fakes | Calls live services | Only if hermetic |
| Environment variables | Declared and part of the key | Inherited from the runner | Only if hermetic |
| Tools | Pinned and hashed | Whatever is on PATH |
Only if hermetic |
For sharding, split by historical duration, not test count, so shards finish together. Refresh those durations from recent runs, because suites grow unevenly.
When selective testing breaks down, and the full-run safety net
Selection pays off only when most changes touch a small part of the graph. If a logging, config or serialization library sits under nearly every package, any edit to it selects almost everything, so those changes get no benefit. No CI setting fixes that. Split the library so consumers depend only on the parts they use, narrow its public interface, and keep frequently edited code out of the most widely imported modules.
Smaller monorepos may not need selection at all. If the full suite finishes in a few minutes, keeping the dependency graph accurate costs more than it saves. The common alternatives are:
| Approach | Fits when | Cost |
|---|---|---|
| Always run everything | Full suite is fast | CI time grows with the repo |
| Time-boxed tiers | Fast tier on PRs, slow tier on merge | Some failures surface after merge |
| Per-service repos | Teams rarely share code | Cross-repo changes and version skew |
Even with good selection, a periodic full run, nightly or on every merge to main, is required. Selection trusts the graph, and the graph misses undeclared runtime dependencies, toolchain updates and changes to shared CI images. Only a full run checks those assumptions. A failure there also points to a missing edge in the graph, so fix the graph as well as the test.
Full-run failures need an owner, or they get ignored. Map each failing test's path to a team through code ownership metadata, such as a CODEOWNERS file, and send the failure to that team's queue rather than a shared channel.
When should it be avoided?
Skip affected-test selection, or postpone it, in these situations:
- The full suite is already fast. If every PR can run everything in a few minutes, selection adds a second source of truth to maintain and a new class of false negatives, in exchange for little saved time.
- Dependencies cannot be declared or enforced. Code that loads other packages' files at runtime, reflection-heavy plugin systems, or dynamic imports driven by configuration all create edges the graph cannot see. Until you can make those edges explicit, selection will skip tests that should have run.
- The graph is dominated by high-fan-in packages. If most PRs edit a library that nearly everything imports, selection degrades to a near-full run plus overhead. Reduce that fan-in first.
- Nobody owns the tooling. Without someone to maintain the global-inputs list, the boundary lint rules and the full-run triage, accuracy erodes silently.
- There is no full-run safety net. If you cannot afford a nightly or post-merge full run, you have no way to detect graph errors. Run everything on PRs instead.
- Correctness requirements outweigh CI time. For code paths where a missed regression is costly, such as release branches or hotfixes, run the full suite regardless of what selection would choose.
- Tests are not hermetic. Suites that depend on wall-clock time, live network services or runner environment variables make both selection and caching unreliable. Fix hermeticity before relying on either.
In these cases, the alternatives in the table above, especially always running everything or time-boxed tiers, are simpler and safer.
Impact on cost and engineering time
Illustrative estimate (assumed numbers, not a benchmark)
Assume 200 packages, a 60-minute full suite whose test time is spread evenly across packages, an average change affecting 5% of the graph, and 2 minutes of fixed overhead for graph computation, checkout and cache restore.
PR test time ≈ fixed overhead + (affected fraction × full-suite time)
| Scenario | Affected fraction | Estimated PR test time |
|---|---|---|
| Full run, no selection | 100% | 60 min |
| Typical leaf or mid-graph change | 5% | 2 + 0.05 × 60 = 5 min |
| Change to a shared logging or config package | 70% | 2 + 0.70 × 60 = 44 min |
The third row shows the weak spot. A package that most of the graph imports turns nearly every change to it into a near-full run. Your real savings therefore depend on how often PRs touch high-fan-in packages, not on the 5% average. Measure that ratio before you trust the projection.
What the savings cost
The minutes come back to engineers as shorter waits, but the platform team pays for them. It must keep build-graph tooling accurate as the repo changes, run and secure a remote cache, operate sharding infrastructure, and maintain the test data and database migrations that integration suites depend on. This is ongoing work for staff, not a one-time setup.
The investment pays off when PR volume is high, the full suite is long enough to block review, and the graph is mostly shallow. If a few core packages dominate your change history, shrink their fan-in first. Selective testing will not help much until you do.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monorepo testing strategy, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.