Every .NET codebase I have worked on hits the same wall sooner or later. The test suite grows, CI goes from three minutes to twenty, and every pull request pays the full price, even a one-line rename deep inside a helper class.
Splitting the suite or buying bigger runners helps for a while. But most of those tests could never have noticed the change in the first place. They run anyway because nothing tells CI which ones matter.
So I built TestDetta: a .NET tool and GitHub Action that reads your diff and runs only the test classes that can observe it. It follows one rule throughout: when it cannot be sure, it runs more tests, never fewer. A missed test is a bug. An extra test only costs CI time.
This post covers how it works, how to try it in a few minutes, and how to check that it holds up on your own code before you trust it.
A real example
Here is a real change to OpenTelemetry .NET: a local variable renamed inside one method. It changes no behaviour, but a normal pipeline still runs all 30 test projects and 274 test classes.
TestDetta works it out like this:
- Projects: 2 of 30 test projects are affected. The other 28 are not even built.
-
Symbols: 1 member changed,
WarnIfConfigFileEnvVarDiffers. -
Coverage map: 8 test classes ran that method on
main. - Result: 8 of 274 classes run, 266 are skipped.
Those 8 classes are exactly the ones that fail when that method is broken. Recorded test time went from 5 m 59 s to 22.4 s.
Try it in two minutes
TestDetta is a .NET tool (it runs on the .NET 10 SDK; your projects can target older frameworks):
dotnet tool install --global TestDetta
Ask what your branch affects, and why:
td affected --base origin/main
See the dotnet test commands it would run, without running anything:
td test --base origin/main --dry-run
And run only the affected tests:
td test --base origin/main
The output looks like this:
$ td test --base origin/main
TestDetta 0.2.2 · 1 changed file · 2 affected test projects
Tests to run
┌──────────────────┬──────────┬─────────┬──────────────────────────────────────────────────────────┐
│ Test project │ Run │ Classes │ Why │
├──────────────────┼──────────┼─────────┼──────────────────────────────────────────────────────────┤
│ tests/Shop.Tests │ filtered │ 3 / 41 │ 3 of 41 test class(es) run or reference the changed code │
│ tests/Api.Tests │ skipped │ 0 / 12 │ no test class runs or references the changed code │
└──────────────────┴──────────┴─────────┴──────────────────────────────────────────────────────────┘
tests/Shop.Tests → 3 classes
Shop.Tests.CartTests
Shop.Tests.CheckoutTests
Shop.Tests.OrderTests
Without a coverage map, TestDetta decides by tracing names through your source. For sharper selection, record a map once on your default branch:
td record
This runs every test with coverage and writes one map per test project to .testdetta/coverage (add .testdetta/ to your .gitignore).
Need machine-readable output? td affected --format json gives you the selection for your own scripts, --format paths lists the test projects, and --format commands prints the dotnet test commands.
How it decides
Each step narrows the selection only when it has evidence. Otherwise it falls back to something wider.
-
Projects. Changed files are mapped to their projects and followed through
ProjectReferences. Build-wide files such asDirectory.Build.props,global.jsonor.editorconfigaffect every project below them. - Symbols. For C#, Visual Basic and F# files, the old and new syntax trees are compared (Roslyn for C# and VB). Comment and whitespace edits select nothing. Otherwise TestDetta knows exactly which types and members changed.
- Coverage. 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.
- 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 map knows.
-
Fallbacks. When a change cannot be traced, the whole test project runs. That covers non-code files the build uses (
appsettings.json,.csproj,.resx), global usings, assembly attributes, and types a framework discovers through reflection (controllers, message handlers, EF configurations).
There is one more safety net: if a filter matches no test at all, TestDetta treats that as its own bug and runs the project again without the filter.
A few details took a lot of work to get right:
- Maps don't have to be fresh. The best map is one recorded at your branch's merge base. A map up to 50 commits older still works: TestDetta also selects the classes that ran anything changed in between that could reach your change.
- Source generators are handled. Generated code is read from the PDBs during recording. When a change reaches a generator in your repository, TestDetta builds the test projects that use it and compares what is generated, framework by framework.
-
#ifcode is not missed. C# files are parsed with each target framework's real preprocessor symbols, and with every symbol their#ifs name. -
Your test configuration is respected. Your own
--filter, run settings and runner arguments are combined with TestDetta's class filter, so it only ever narrows your runs.
In GitHub Actions
The usual setup records a map on every push to main and runs only the affected tests on pull requests:
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
actions: read # lets pull requests find the newest coverage map in the Actions cache
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7 # pin to a commit SHA
with:
fetch-depth: 0 # TestDetta compares with the merge base
persist-credentials: false
- uses: actions/setup-dotnet@v6 # pin to a commit SHA
with:
global-json-file: global.json
# Full test run of main, which also records the coverage map
- if: github.event_name == 'push'
uses: TestDetta/action@a2f55a8252840a367f2f7a9ebd6733de3ebfb2f5 # v0.2.2
with:
mode: record
# Pull requests run only what the change can affect
- if: github.event_name == 'pull_request'
uses: TestDetta/action@a2f55a8252840a367f2f7a9ebd6733de3ebfb2f5 # v0.2.2
with:
mode: test
dotnet-test-args: --logger trx
Maps live in the Actions cache, keyed by commit, and pull requests read the map of their merge base. Every run writes a job summary explaining what ran and why.
Not on GitHub? GitLab CI, Azure DevOps, Jenkins and Bitbucket use the same two commands (td record and td test) with a folder of per-commit maps on a shared disk or object storage (--maps-dir).
Don't trust it, check it
A tool that decides which tests to skip has to earn your trust on your own codebase, not on a marketing page. TestDetta ships two ways to check it, and neither needs a license:
Shadow mode (td test --shadow, or mode: shadow in the action) keeps running your full suite and compares the result with what the selection would have run. When a test fails outside the selection, it reruns that class to tell a real miss from a flaky or pre-existing failure. Run it for a couple of weeks of normal pull requests and you have evidence from your own code.
Audit (td audit, or mode: audit) doesn't wait for real failures. It breaks your code on purpose, one member at a time with IL mutants in a copy of the working tree, runs every test that can see each change, and reports any failing class the selection left out. It also reports the 95% upper bound of the miss rate. Your sources and build output are only read.
How it is tested
I run the same check at scale on 53 open-source .NET repositories, including Polly, Newtonsoft.Json, AutoMapper, Serilog, Markdig, Refit, Autofac, NodaTime, FastEndpoints, FluentValidation and OpenTelemetry. Members are changed one at a time, and every test class that fails must be one TestDetta selected.
From the latest round (2026-10-13 to 2026-10-15):
- 3,071 changes broke at least one test.
- 5 misses, each a documented limitation (more on those below). Earlier real misses were fixed, each with a regression test that fails without the fix.
- The median repository ran 35% of its test classes per change. In 20 of the 53 repositories it was a quarter or less, and Polly got down to 3%.
To be upfront: some repositories gain much less. Mapperly ran 97% and AutoMapper 86%, because nearly every test there goes through the same central code paths. If most of your tests touch everything, test impact analysis can't save much, and the evaluation shows that honestly.
Limitations
TestDetta is static and coverage-based analysis, not magic. Here is what it can't fully see:
-
Code that runs once per process (static constructors, lazy singletons, EF model building) is credited to the first test class that triggers it. TestDetta traces changes to such code by name instead, but a cache you wrote by hand (a
Dictionarychecked withTryGetValue) can still hide its users. - Reflection and dynamic loading are only partly visible. Types a framework discovers this way run their whole test project.
-
F# signature files (
.fsi) are not analysed, so a change to one runs its project's dependents. - Tests skipped while recording have no coverage, for example a missing Docker daemon. Record where your tests actually run.
The full list is on the Limitations page. My recommendation: keep running the full suite on your default branch. Recording does that anyway, so anything a selection could ever hide is still caught before release.
What's supported
- Test frameworks: xUnit v2, xUnit v3, NUnit, MSTest and TUnit
-
Runners: VSTest and Microsoft.Testing.Platform (read from
global.json) - Languages: C#, Visual Basic and F#
- CI: GitHub Actions, GitLab CI, Azure DevOps, Jenkins, Bitbucket and on-premises setups
Privacy and licensing
None of your code leaves your CI. Analysis, coverage maps and test results stay on your machines. The only network call is an optional license renewal, at most once a day, that sends the license and nothing else.
TestDetta is free for public repositories on GitHub Actions and GitLab CI, with every feature and no sign-up. It detects public repositories from the CI itself. Private repositories need a paid license, with a 30-day free trial that needs no card. A license problem never fails your build: without a valid license, every test simply runs, as it did before TestDetta.
Links
- Website and docs: testdetta.com
- How it decides, step by step: testdetta.com/docs/how-it-works
- Validate it on your repository: testdetta.com/docs/validate
- GitHub Action: github.com/TestDetta/action
- NuGet: nuget.org/packages/TestDetta
If you try it, I would love to hear how it goes, especially if it misses a test. A miss is the one bug I care about most. Leave a comment here or write to support@testdetta.com, and I'll dig in.
How does your team deal with a slow .NET test suite today? I'm curious what has worked for you.
Top comments (1)
The shadow mode is a very good way to earn trust, since it gives evidence from your own pull requests instead of a benchmark.
One question about the once-per-process limitation: does the same apply to xUnit collection fixtures? An
ICollectionFixture<T>is created once and shared by every class in the collection, so if a change only reaches code in the fixture's constructor, I'd expect the coverage to be credited to whichever class in that collection happened to run first. Does the name tracing catch that case, or does it fall back to running the whole project?