Running Only Tests Affected by a Change

The cheapest test in your suite is the one you correctly do not run. A one-line change to a single utility cannot break the ninety percent of your suite that never imports it, yet a naive pipeline runs all of it on every commit, so suite time scales with repository size instead of with the size of the change. Affected-test selection scopes a run to only the tests reachable from the files a branch touched, turning suite time into a function of the diff. This guide is for CI engineers running Vitest who want to adopt --changed safely — because the danger is a wrong skip, a test that should have run but did not, letting a regression through. We cover the dependency-graph reasoning, a reproducible setup, the base-ref that makes it correct on pull requests, and the full-run backstop every affected-selection pipeline needs. This is the diff-scoping lever in CI test orchestration.

Root Cause Analysis

The redundancy is a graph property. Your source and test files form a dependency graph: a test file imports modules, which import other modules, down to the leaves. A change to a file can only alter the behaviour of tests whose import chain reaches that file — everything else is provably unaffected, because no path in the graph connects the change to it. A naive vitest run ignores this structure entirely and executes every test regardless of whether the change could reach it, so as the graph grows the wasted fraction grows with it.

Affected selection restores the structure. Vitest builds the module graph, computes which files changed relative to a base, and runs only the tests transitively connected to those changes. On a typical feature branch this prunes the run dramatically, because most changes are local. The reason this is not simply the default is that its safety depends entirely on the graph being complete. Static imports are traced perfectly, but a test that reaches production code through a path the graph cannot see — a dynamic import() built from a runtime string, a config file read with fs, a code-generation step, a global side effect — can be wrongly pruned, and a wrongly pruned test is a silent coverage hole on that commit. So affected selection is a speed optimisation you must bound with a correctness backstop: fast on pull requests, but never the last word before a change reaches main. That backstop is a full run, which is also where a coverage gate belongs, connecting to enforcing coverage thresholds in a monorepo.

Dependency graph selecting only reachable tests A changed module propagates along import edges to the two tests that reach it, while an unrelated test with no path to the change is pruned from the run. changed module utils/date.ts imports date.ts invoice.ts imports date.ts report.ts invoice.test RUNS report.test RUNS auth.test PRUNED — no path
Only tests with an import path to the change are selected; the rest are pruned.

Reproducible Setup

Vitest’s --changed flag takes an optional git ref and runs only tests affected by files changed since that ref. With no argument it uses uncommitted changes; with a base branch it uses the whole diff.

npm install -D vitest
npx vitest run --changed                 # uncommitted changes only
npx vitest run --changed origin/main     # everything different from main
// vitest.config.ts — a git base helps forkable graph resolution
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    // include patterns must cover every test so the graph is complete
    include: ['src/**/*.{test,spec}.ts'],
    reporters: ['default', 'json'],
  },
});

The include glob matters more than it looks: a test the graph never registers cannot be selected or correctly pruned, so make sure every test file matches the include pattern before you trust affected selection.

Implementation

Step 1 — Fetch enough history for git to compute the diff. GitHub Actions checks out a shallow clone by default, and --changed origin/main needs the base commit present. Fetch the base ref, or the diff is empty and every test is wrongly considered unaffected.

# .github/workflows/pr.yml
name: pr
on: pull_request
jobs:
  affected:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }        # full history so the base ref exists
      - uses: actions/setup-node@v4
        with: { node-version-file: '.nvmrc', cache: 'npm' }
      - run: npm ci
      - run: npx vitest run --changed origin/${{ github.base_ref }}

Step 2 — Use the pull request’s true base, not a hardcoded branch. github.base_ref is the branch the PR targets, so the diff is exactly the PR’s changes. Hardcoding main breaks for PRs that target a release branch and silently over- or under-selects.

Step 3 — Combine affected selection with sharding and bail, in the right order. Affected selection prunes the suite first, then sharding parallelises the remainder, then bail stops early on failure. The three compose multiplicatively, which is the whole point of CI test orchestration.

      - run: >
          npx vitest run
          --changed origin/${{ github.base_ref }}
          --shard=${{ matrix.shard }}/2
          --bail=1

Step 4 — Add the full-run backstop on the merge to main. The pull-request pipeline runs affected tests only, but the push to main runs everything, so a wrong skip on a PR is caught before it can compound. This is the correctness guarantee that makes the PR-side pruning safe.

# .github/workflows/main.yml — the backstop
name: main
on:
  push: { branches: [main] }
jobs:
  full:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: '.nvmrc', cache: 'npm' }
      - run: npm ci
      - run: npx vitest run          # no --changed: the whole suite runs

Step 5 — Force a full PR run when high-blast-radius files change. A change to a lockfile, a shared config, a test setup file, or the CI workflow itself can affect tests the module graph does not connect. Detect those paths and drop --changed for that PR so the graph’s blind spots cannot bite.

# if a high-blast-radius file changed, run everything instead of the affected set
if git diff --name-only origin/"$BASE" | grep -qE 'package-lock.json|vitest.setup.ts|tsconfig'; then
  npx vitest run
else
  npx vitest run --changed origin/"$BASE"
fi
Affected run on pull requests, full run backstop on main Pull requests run only the affected tests for speed, and the merge to main runs the whole suite as a backstop that catches any wrongly skipped test. Pull request affected tests only — fast scoped to the diff may miss a hidden edge Merge to main full suite — the backstop catches wrong skips completeness guarantee merge
Fast affected runs on PRs, backed by a full run on every merge to main.

Verification

Affected selection is verified by proving two opposite properties: it prunes what it should and it never prunes what it should not. The first is easy — change one leaf file and confirm the run shrinks. The second is the one that matters — confirm that for a given change, the affected set is a superset of every test whose import chain reaches the change.

# a leaf change should select a small set
echo '// touch' >> src/utils/date.ts
npx vitest run --changed HEAD --reporter=json --outputFile=affected.json
# the full run is the ground truth for what could be affected
npx vitest run --reporter=json --outputFile=full.json
// scripts/assert-affected.ts — every selected test also exists in the full run
import { readFileSync } from 'node:fs';

const names = (p: string): Set<string> =>
  new Set(JSON.parse(readFileSync(p, 'utf8')).testResults.map((r: any) => r.name));

const affected = names('affected.json');
const full = names('full.json');

// affected must be a strict subset of the full suite — never invents tests
for (const t of affected) {
  if (!full.has(t)) throw new Error(`Selected a test not in the full suite: ${t}`);
}
if (affected.size >= full.size) throw new Error('Affected set did not prune anything.');
console.log(`Affected selection pruned ${full.size - affected.size} of ${full.size} test files.`);

The subset check catches a misconfigured graph that invents or misnames tests, and the pruning check confirms selection actually happened. But neither can prove a wrong skip, because a wrongly skipped test is absent from the affected set by definition — that is precisely why the full-run backstop on main is not optional. Verification bounds the risk; the backstop eliminates it.

What verification proves versus what the backstop catches The subset assertion proves affected tests are real and pruning happened, but only the full-run backstop on main can catch a wrongly skipped test. Assertions prove affected ⊆ full suite no invented tests pruning happened Backstop catches the wrong skip absent from the affected set only a full run sees it
Verification bounds the risk; only the full-run backstop eliminates the wrong skip.

Troubleshooting

Every test runs even on a tiny change. The diff came back empty or all-encompassing because git cannot see the base commit — a shallow checkout omitted it. Set fetch-depth: 0 on the checkout and pass the real base with origin/${{ github.base_ref }}; an unreachable ref makes Vitest fall back to running everything, which is safe but defeats the optimisation.

A regression reached main that PR tests did not catch. A test that should have run was pruned because its path to the change was invisible to the module graph — typically a dynamic import, a config file read at runtime, or a global side effect. The full-run backstop on main should have caught it; if main is green too, the test genuinely does not cover that path. Add the missing static coverage, and add the file that surprised you to the high-blast-radius list from Step 5 so future PRs run the full suite when it changes.

Affected selection is slower than a full run on small repos. Building and diffing the module graph has a fixed cost that can exceed the time saved when the suite is already fast. Reserve --changed for suites big enough that pruning pays for the graph computation, and keep small repositories on a plain full run. Where flakiness rather than size is the problem, address it with quarantining flaky tests in CI instead of narrowing the run.

FAQ

How does Vitest decide which tests are affected?

Vitest builds the module dependency graph from your test files down through their static imports, computes the set of files that differ from the git ref you pass, and selects every test whose import chain transitively reaches one of those changed files. Tests with no path to any changed file are provably unaffected and are pruned. The selection is only as complete as the graph, so it traces static import statements reliably but cannot see edges created dynamically at runtime, which is the source of the wrong-skip risk.

Is it safe to rely on affected selection as my only CI gate?

No, and treating it as such is the central mistake. Use it to make pull-request feedback fast, but always back it with a full run on the merge to main, so any test the module graph failed to connect to a change still runs before that change becomes the trunk. Affected selection bounds the blast radius of a run; the full-run backstop is what guarantees nothing was silently skipped. The two together give you speed on PRs and completeness on main.

What counts as a high-blast-radius change?

Anything that can affect tests without a traceable import edge to them: a lockfile change that alters dependency behaviour, a shared test-setup or global-mock file, a root tsconfig or build config, environment configuration, or the CI workflow itself. For those, the module graph’s reachability analysis under-selects because the influence flows through channels it does not model, so drop --changed and run the whole suite for that pull request rather than trusting a pruned set.

Can I combine affected selection with sharding and bail?

Yes, and they compose cleanly in that order. Affected selection prunes the suite to the tests a diff can reach, sharding parallelises whatever remains across runners, and bail stops a shard at its first failure on the gating path. Each lever multiplies the previous one’s saving, so a large suite on a small change can go from many minutes to well under one. Apply --changed first in the command so the shard split operates on the already-pruned set rather than the whole suite.