Running fewer tests is easy. Proving that the omitted tests do not matter to a particular change is the engineering problem.
A pull request changes one library. CI follows reverse dependencies, identifies twenty affected tests, and skips the remaining thousand. That can be an excellent optimization. It can also be a very fast way to miss a configuration change consumed at runtime by a service whose dependency declaration never mentions it.
Changed-test selection is safe enough when the team can explain what its model covers, identify changes outside that model, and produce evidence that the selection policy behaves as intended. The word policy matters. A query supplies a set of targets. A policy decides whether that set is adequate evidence for the change in front of you.
Define the claim before optimizing the command
Start with a bounded claim: for a particular test population and supported configuration, a selector should include every test whose result could be affected by the proposed change. It can select more tests than necessary. Selecting an unaffected test costs time; omitting an affected test can invalidate the gate.
Even a perfectly implemented selector cannot compensate for a test suite that never exercises the behavior. Full-suite success is broader evidence, not a proof of correctness. Keep security checks, compatibility checks, integration tests, and release requirements visible rather than silently treating a selected unit-test pass as a universal approval.
Distinguish three things in CI output:
- Changed inputs: the files, targets, and configuration changes the selector considered.
- Selected checks: what it chose and the reason for each inclusion or fallback.
- Verification boundary: what a successful run supports, and which other gates still apply.
This article concerns that boundary. A Slow Local Test Is a Product Decision, Not Just a CI Problem addresses whether faster feedback deserves investment. The investment is worthwhile only if the faster feedback remains trustworthy.
Dependency-aware selection needs a complete-enough graph
A dependency graph can represent source files, libraries, generated outputs, tools, and tests. Reverse traversal asks which targets depend on a changed input. The usefulness of the answer depends on whether the relevant relationships exist in the graph and whether the query includes the necessary universe.
Bazel's query reference documents rdeps for reverse dependencies and tests for expanding test targets and suites. It also explains that ordinary query operates on an unconfigured graph: results can conservatively include dependencies from multiple configurations. That over-approximation is useful, but it does not make an undeclared relationship appear.
For a hypothetical repository where //lib:parser.cc is a declared source label, this is an illustrative investigation:
bazel query \
'tests(rdeps(//..., //lib:parser.cc))' \
--output=label
Read it as: find reverse dependents of this source within the workspace target universe, then identify the associated tests. This is not a complete changed-test runner. It assumes that the label exists, the relevant tests are represented, and the workspace graph captures their dependencies. External consumers and independent integration environments need separate treatment.
The Bazel query guide provides examples of reverse traversal, source-label discovery, and test-suite expansion. Use those operations to inspect your actual graph before embedding them in automation. Do not drop implicit or tool dependencies simply because doing so makes a smaller, more appealing list.
The diff is an input, not the selection algorithm
A selector needs a well-defined comparison point. A developer's last local commit is not necessarily the base that CI will merge against. Record the tested revision, base revision, supported configurations, and selector version so a reviewer can reconstruct the decision.
Map additions, modifications, renames, and deletions deliberately. A new file may not yet belong to any declared target. A deleted source may have no label in the current graph even though it had dependents in the base graph. Treating either as “zero affected tests” is an implementation bug disguised as efficiency.
For deletions and dependency-edge changes, use before-and-after graph information when your system supports it. Otherwise take the documented broader fallback. When the base revision cannot be resolved or the selector cannot load the graph, the outcome is selection unavailable, not nothing needs testing.
An empty result deserves an explanation. Documentation outside the build can legitimately require no executable tests. An unmapped runtime configuration file does not earn the same treatment. Distinguish an approved exclusion from missing knowledge.
Make the blind spots concrete
The interesting failures occur where a repository's declared structure differs from how the software really runs.
Undeclared and runtime inputs
A test may open a fixture by path, discover a plugin, read an environment variable, or fetch a service schema. If those inputs are not modeled, reverse dependencies cannot protect you. Either declare them, put the change class behind broader checks, or explicitly retain an independent gate.
A shared configuration default deserves particular suspicion. It may affect many services while appearing in no compile-time dependency chain. “The file isn't code” is a poor reason to exclude a file that changes production behavior.
Generated code and generators
Changing a generated file and changing the generator are different events. A generator change can affect outputs that were not regenerated in the pull request. Include generator inputs, templates, and the supported regeneration path in the verification story. Where generation lives outside the build graph, the selection policy must account for that boundary.
Build definitions, dependencies, and toolchains
A build-rule edit can remove an edge that previously connected a test to a changed input. A lockfile or compiler change can affect a large population without modifying application sources. Configuration-specific selection can also miss another supported platform if it reasons only about the current runner.
Do not assume every broad change requires every possible job forever. Start conservatively, then narrow a specific class only after its modeling and validation are understood.
Write down the fallback rules
A useful policy identifies routine selectable changes and the conditions that require wider verification. The following table is a starting point, not a universal repository specification:
| Change or condition | Initial verification choice | Evidence needed to narrow it |
|---|---|---|
| Ordinary source edit with declared dependents | Selected affected tests plus standing gates | Reliable mapping and supported configuration coverage |
| New, deleted, or renamed input with uncertain mapping | Full relevant suite | Before-and-after ownership and dependency mapping |
| Shared runtime configuration or fixture | Full consuming test population | Declared consumers or validated change-class rules |
| Generator, build rules, lockfile, or toolchain | Broad build/test fallback | Modeling of affected outputs and configurations |
| Selector error, incomplete checkout, or missing base | Full fallback or block if unavailable | Successful selection with recorded inputs |
| Approved documentation-only change | Documented non-test checks | Explicit exclusion that cannot cover executable inputs |
Define full operationally. It should name the repository's relevant supported test jobs and standing integration checks, not promise every conceivable environment. If the fallback cannot complete, keep the gate unresolved. Do not convert an infrastructure failure into permission to merge.
Run selection in shadow mode first
Before selection becomes a blocking gate that replaces broader work, compute the selected set while still running the normal full test population. Record what selection would have skipped and compare results for the same candidate revision and relevant environment.
Look specifically for failures outside the selected set. Then investigate:
- Was the failed check genuinely affected by the change?
- Was an input missing from the graph or file-to-target mapping?
- Did the comparison use the wrong base, configuration, or test population?
- Was the result caused by flaky behavior or an independent infrastructure failure?
- Which policy or modeling change prevents recurrence?
Do not dismiss an omitted failure as flaky just because that explanation preserves a good optimization result. Reproduction and failure classification are work the team must do.
A period with no misses is useful evidence, especially if it spans representative changes. It is not proof that the next unrepresented change class is safe. Supplement passive observations with small, deliberate changes to shared fixtures, configuration, generators, and deleted dependencies in a disposable test environment. Check whether the intended fallback or selection actually occurs.
Give the selector an owner and an audit trail
The selection policy is part of the developer platform. Assign an owner who can change it, investigate misses, and temporarily widen verification without a political negotiation over pipeline speed.
Retain enough evidence to answer a later incident question: which base and candidate were tested, what inputs changed, which checks were selected, what was skipped, and whether a fallback occurred. A green badge that says only “tests passed” hides the distinction the whole system depends on.
Measure elapsed time and resource savings alongside missed affected checks, unmapped inputs, fallback frequency, and the age of unresolved modeling gaps. A rising fallback rate may indicate a healthy conservative policy, or it may identify the next modeling investment. Interpret the reason rather than setting a target that pressures people to suppress fallback.
Keep periodic broader runs and appropriate release gates after adoption. They provide a continuing comparison point, but a later full run does not retroactively protect a merge already approved by an incomplete selector.
Make the smaller check earn trust
Changed-test selection is most useful when its claim is narrower and clearer than “CI is faster now.” Declare the supported change classes, represent their dependencies, broaden checks when information is missing, and compare the selector with independent evidence.
For humans and agents using the resulting checks, A Safe Developer Feedback Loop for AI Agents explains how to keep verification results tied to the actual change. The Build Systems & CI/CD hub connects that workflow to the larger build system.
My default is to accept extra tests before accepting unexplained omissions. Optimize the well-modeled path; make uncertainty visible. More practical engineering guidance is available at Slaptijack.