How it decides
TestDetta narrows the selection one step at a time, and only where it has evidence. Whatever it cannot trace falls back to running more.
Select more, never less
A missed test is a bug; an extra test only costs CI time. Every rule below follows from that. When the analysis is unsure whether a change affects a project or a class, it treats it as affected, and the reason shows up in the output.
The five steps
1. Projects
Changed files are mapped to their projects and followed through ProjectReferences to every project that depends on them. Build-wide files such as Directory.Build.props, global.json and .editorconfig affect every project below them.
2. Symbols
For C# files, the old and new syntax trees are compared. Comment and whitespace edits select nothing; otherwise TestDetta knows which types and members changed.
3. Coverage
After td record, a map of which test class ran which method, recorded at the base commit, gives the test classes for changed methods, properties and constructors directly.
4. Names
What coverage cannot see, such as new code, fields and constants, is traced through the source by name until it reaches test classes or code the coverage map knows.
5. Fallbacks
A whole test project runs when a change cannot be traced:
- non-C# files in a project, such as
appsettings.json, the.csprojor a.resx; - global usings and assembly attributes;
- types a framework discovers through reflection, such as controllers, message handlers and EF configurations.
The filter self-check
TestDetta runs a selected project with a filter on its test classes. A filter that matches no test is treated as a bug in TestDetta: the project is run again without it.
Coverage maps
td record runs every test with coverage and writes one map per test project. Maps are best recorded at the merge base of your branch, and in CI you record one for every commit on the default branch.
Older maps and the drift bridge
A map recorded up to 50 commits before the merge base is still used (--max-map-age, default 50; 0 uses only a map recorded at the merge base). Code changed in between could have created calls the map does not know, so TestDetta also selects the classes that ran any changed-since member that can reach your change, and traces new code by name.
When something in between cannot be described by members, such as a build file or appsettings.json, the affected test projects run completely. --drift-fallback name decides them by name instead, as without a map: faster, but blind to calls wired by configuration or reflection.
Without a map
The first pull requests after setting up, or ones far behind, find no map within reach. TestDetta then selects by name only, which is safe but selects more, and says so in its log. Selection becomes precise once td record has run on the default branch.
Seeing why
td affected and td test --dry-run print every affected test project with the reason it runs filtered, completely or not at all; td affected --format json adds the decision for each test class. -v writes every decision to stderr, for example why a project was affected or which change a class ran.
$ td test --dry-run --base origin/main
td: 1 changed file(s), 2 affected test project(s).
Tests to run:
filtered tests/Shop.Tests/Shop.Tests.csproj (3 of 41 test class(es) run or reference the changed code)
skip tests/Api.Tests/Api.Tests.csproj (no test class runs or references the changed code)