Logo TestPrune

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

dotnet add package TestPrune.Falco

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

  1. Checks if any changed file is a known handler (from the route table)
  2. Looks up which URL patterns that handler serves
  3. Scans your integration test .fs files for those URLs (/api/users/{id} matches /api/users/123 in 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
  4. 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>], a FactAttribute subclass such as [<SkippableFact>], the NUnit/MSTest equivalents) or — for a class — an inherit clause, 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.

Documentation

val routeStore: obj
union case Option.Some: Value: 'T -> Option<'T>
val extension: obj
val affected: obj
val outcome: obj

Type something to start searching.