TestPrune
Only run the tests affected by your change.
TestPrune analyzes your F# code to figure out which functions depend on which, then uses that to skip tests that couldn't possibly be affected by what you changed.
Why?
When your test suite takes minutes but you only changed one function, running everything is wasteful. TestPrune builds a map of your code — which functions call which, which tests cover which code — and uses it to pick just the tests that matter.
Change multiply? Only the multiply tests run. Change a type that
three modules depend on? Those three modules' tests run. Add a new
file? Everything runs, just to be safe.
Quick example
Say you have a math library and some tests
(from examples/SampleSolution):
// src/SampleLib/Math.fs
module SampleLib.Math
let add x y = x + y
let multiply x y = x * y
// tests/SampleLib.Tests/MathTests.fs
[<Fact>]
let ``add returns sum`` () = Assert.Equal(5, add 2 3)
[<Fact>]
let ``multiply returns product`` () = Assert.Equal(12, multiply 3 4)
You change multiply. TestPrune figures out that only
multiply returns product needs to run — and skips add returns sum.
Getting started
|
1. Index your project
First, build a dependency graph of your code. This parses every .fs
file and stores the results in a local SQLite database:
let checker = FSharpChecker.Create()
let db = Database.create ".test-prune.db"
let projOptions = getScriptOptions checker fileName source |> Async.RunSynchronously
match analyzeSource checker fileName source projOptions |> Async.RunSynchronously with
| Ok result ->
let normalized = { result with Symbols = normalizeSymbolPaths repoRoot result.Symbols }
db.RebuildProjects([ normalized ])
| Error msg -> eprintfn $"Failed: %s{msg}"
Caching works at two levels — project and file — to skip expensive re-analysis for unchanged code:
// Project-level: skip the entire project if nothing changed
match db.GetProjectKey("MyProject") with
| Some key when key = currentKey -> () // skip
| _ ->
// File-level: skip individual files within a changed project
match db.GetFileKey("src/Lib.fs") with
| Some key when key = currentFileKey ->
// Load cached results from DB instead of re-analyzing
let symbols = db.GetSymbolsInFile("src/Lib.fs")
let deps = db.GetDependenciesFromFile("src/Lib.fs")
let tests = db.GetTestMethodsInFile("src/Lib.fs")
// ... use cached data
| _ ->
// File changed — run FCS analysis
// ... analyzeSource, then db.SetFileKey(...)
db.RebuildProjects([ combined ])
db.SetProjectKey("MyProject", currentKey)
Cache keys can be anything that changes when source files change. Good options:
-
VCS tree hash (recommended) —
jj log -r @ -T commit_idorgit rev-parse HEADgives a content-addressed hash that changes exactly when files change. Fast and correct across branch switches. -
File metadata — path + size + mtime. The CLI uses this by default.
Simple but can be wrong after
git checkout(mtime updates even if content is identical).
2. Find affected tests
When you're ready to test, compare the current code against the index to find what changed, then ask which tests are affected:
match selectTests db changedFiles currentSymbolsByFile with
| RunSubset tests -> // only these tests need to run
| RunAll reason -> // something changed that we can't analyze — run everything
RunSubset gives you a list of specific test methods. RunAll is the
safe fallback for situations like .fsproj changes or brand new files
where TestPrune can't be sure what's affected.
3. (Bonus) Find dead code
The same dependency graph can find code that's never reached from your entry points:
let result = findDeadCode db [ "*.main"; "*.Program.*" ] false
// result.UnreachableSymbols — functions nothing calls
By default, symbols in test files are excluded from the report. Pass
true for includeTests to find dead code in your test suite too
(e.g. unused test helpers):
let result = findDeadCode db [ "Tests.MyTests.*" ] true
How it works
-
Index — Parse every
.fsfile, record which functions/types exist and what they depend on. Store in SQLite. - Diff — Look at what files changed since last commit.
- Compare — Figure out which specific functions changed (added, removed, or modified).
- Walk — Follow the dependency graph from changed functions to find every test that transitively depends on them.
- Run — Execute only those tests.
If anything looks uncertain (new files, project file changes), it falls back to running everything. Better to run too many tests than miss a broken one.
Extensions
Some dependencies don't show up in code — like HTTP routes mapping to handler files. Extensions let you teach TestPrune about these:
type ITestPruneExtension =
abstract Name: string
abstract FindAffectedTests:
db: Database -> changedFiles: string list -> repoRoot: string -> AffectedTest list
TestPrune.Falco is an extension for Falco
web apps that maps URL routes to integration tests.
Packages
Package |
What it's for |
|---|---|
The library — use this in your build system or editor |
|
Extension for Falco web apps (route → test mapping) |
|
|
CLI tool (reference implementation — see below) |
CLI (reference implementation)
The TestPrune CLI is a reference implementation — it shows how to
wire up the library, but it's not optimized for production use. In
particular, FSharp.Compiler.Service analysis is inherently slow (it
type-checks your entire project), so the CLI re-indexes serially and
can take a while on large codebases. For real workflows, use
TestPrune.Core directly in your build system where you can cache
aggressively, parallelize across projects, and integrate with your
existing tooling.
test-prune index # Build the dependency graph
test-prune run # Run only affected tests
test-prune status # Show what would run (dry-run)
test-prune dead-code # Find unreachable production code
test-prune dead-code --include-tests # Include test files in report
Documentation
Design choices
Static analysis, not coverage. TestPrune reads your code's AST instead of instrumenting test runs. This means you don't need to run tests to build the graph, and there's no flaky-coverage problem. The tradeoff: it might run a few extra tests, but it won't miss broken ones. Note that FSharp.Compiler.Service type-checking is not instant — plan on caching aggressively (see the file- and project-level caching APIs) and parallelizing across projects in your integration.
Safe by default. When in doubt, run everything. A missed broken test is much worse than running a few unnecessary ones.
Single-file storage. The dependency graph is one .test-prune.db
file. No servers, no services. Rebuilds are atomic.
type Async = static member AsBeginEnd: computation: ('Arg -> Async<'T>) -> ('Arg * AsyncCallback * objnull -> IAsyncResult) * (IAsyncResult -> 'T) * (IAsyncResult -> unit) static member AwaitEvent: event: IEvent<'Del,'T> * ?cancelAction: (unit -> unit) -> Async<'T> (requires delegate and 'Del :> Delegate) static member AwaitIAsyncResult: iar: IAsyncResult * ?millisecondsTimeout: int -> Async<bool> static member AwaitTask: task: Task<'T> -> Async<'T> + 1 overload static member AwaitWaitHandle: waitHandle: WaitHandle * ?millisecondsTimeout: int -> Async<bool> static member CancelDefaultToken: unit -> unit static member Catch: computation: Async<'T> -> Async<Choice<'T,exn>> static member Choice: computations: Async<'T option> seq -> Async<'T option> static member FromBeginEnd: beginAction: (AsyncCallback * objnull -> IAsyncResult) * endAction: (IAsyncResult -> 'T) * ?cancelAction: (unit -> unit) -> Async<'T> + 3 overloads static member FromContinuations: callback: (('T -> unit) * (exn -> unit) * (OperationCanceledException -> unit) -> unit) -> Async<'T> ...
--------------------
type Async<'T>
val string: value: 'T -> string
--------------------
type string = System.String
TestPrune