TestPrune.Falco
When you change a route handler, only re-run the integration tests that hit that route.
This is an extension for TestPrune
that connects Falco URL routes to integration tests. If you change the
handler for /api/users/{id}, it finds the tests that make requests to
that URL and runs just those.
Status: early alpha. This is a young project, substantially AI-written, and still finding its shape. Behavior and APIs shift between versions, so pin a version and expect surprises.
Installation
|
How to use it
1. Store your route mappings during indexing
Routes are the one thing TestPrune cannot read out of your code — they
live in a route DU plus runtime wiring, not in the symbol graph — so you
seed them. RouteStore owns that table: it lives inside TestPrune's cache
database, but TestPrune.Core knows nothing about it (it just hands out a
connection, via toPluginStore).
Each entry is a RouteHandlerEntry; Rebuild clears and rewrites the
whole route table, so re-seed it on every indexing run. Set
HandlerFunction to the short Module.function serving the route so a
route's tests link only to that function (a one-function change to a
multi-route file selects only that route's tests); None falls back to a
whole-file match (every function in the file):
open TestPrune.Ports // toPluginStore
open TestPrune.Falco // RouteStore, RouteHandlerEntry
let routeStore = RouteStore(toPluginStore db)
routeStore.Rebuild [
{ UrlPattern = "/api/users/{id}"
HttpMethod = "GET"
HandlerSourceFile = "src/Web/Handlers/Users.fs"
HandlerFunction = Some "Users.get" }
{ UrlPattern = "/api/users/{id}"
HttpMethod = "PUT"
HandlerSourceFile = "src/Web/Handlers/Users.fs"
HandlerFunction = Some "Users.update" }
]
2. Create the extension and query affected tests
The extension reads routes through that same RouteStore:
open TestPrune.Ports // toSymbolStore
open TestPrune.Extensions // ITestPruneExtension
let extension =
FalcoRouteExtension(
integrationTestProject = "MyApp.IntegrationTests",
integrationTestDir = "tests/MyApp.IntegrationTests",
routeStore = routeStore
)
// Affected test classes, directly:
let affected = extension.FindAffectedTestClasses(changedFiles, repoRoot)
// -> [{ TestProject = "MyApp.IntegrationTests"; TestClass = "UsersTests" }]
To feed those couplings into TestPrune's dependency graph instead, refresh the extension's edges on every index build, after the build's own analysis is written:
let outcome = refreshExtensionEdges db repoRoot [ extension ]
// -> [ Refreshed("Falco Routes", edgeCount) ]
The edges cover every route in the route table (test symbol -> handler symbol, kind SharedState) and replace the ones the previous build stored, so they never depend on which files changed.
How it works
- Checks if any changed file is a known handler (from the route table)
- Looks up which URL patterns that handler serves
-
Scans your integration test
.fsfiles for those URLs (/api/users/{id}matches/api/users/123in your test code). A route with no literal path text of its own — the root route/, or a param-only route like/{lang}— is matched only where a quote opens it ("/","/?lang=en",'/'). Anywhere else/on its own is ordinary punctuation: matching it loosely made the root route match//, the F# comment token, and so select every file with a comment in it -
Returns the test classes from files that reference affected routes
(
FindAffectedTestClasses), or those couplings as graph edges (AnalyzeEdges). A class or module counts as a test declaration only when its own span carries a test attribute ([<Fact>],[<Theory>], aFactAttributesubclass such as[<SkippableFact>], the NUnit/MSTest equivalents) or — for a class — aninheritclause, since xUnit runs test methods a base class declares. Fixtures,[<CollectionDefinition>]markers and plain helpers declare no tests and are never returned
If you also mark a composition root
TestPrune.Core lets you mark your route table with
[<TestPrune.CompositionRoot>] so that editing one handler no longer selects
every test whose fixture boots the app. That is only safe because this
extension attributes each route to its own tests directly — cutting the
composition edge leaves the route→test edge behind to select the right tests.
The limit to know: this extension matches tests that name a URL. A browser
test that reaches the route by clicking (page.ClickAsync "#stop-impersonating")
is not attributed, so a composition-root barrier would drop it. Don't mark a
composition root in a repo whose browser tests navigate by UI interaction.
TestPrune