Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/routeless-tests-typed-clients && read --section="top" 0%
.NET

Route-less Tests: Integration Testing With Typed Clients

FastEndpoints ships a testing package where tests call endpoints by class name, apps boot once per fixture, and even command handlers can be faked - the route string never appears in a test. Part 14 of FastEndpoints in Depth.

By Kumar Chandrachooda 20 Jun 2025 5 min read

The classic integration-test smell in ASP.NET Core projects is the route string. client.PostAsJsonAsync("/api/orders/v2", dto) works until someone renames the route, and then it still works — returning a 404 that the test dutifully reports as a failed assertion three layers away from the cause. Route strings in tests are stringly-typed coupling to exactly the thing your framework already models as a type.

FastEndpoints' testing package removes the string. Tests call endpoints by class:

var (rsp, res) = await App.Client.POSTAsync<CreateOrderEndpoint, CreateOrderRequest, CreateOrderResponse>(
    new() { CustomerId = "c-42", Lines = [new("SKU-1", 2)] });

rsp.StatusCode.ShouldBe(HttpStatusCode.OK);
res.OrderId.ShouldNotBe(Guid.Empty);

Rename the route, add a version, move it behind a prefix — the test doesn't change, because the test never knew the route. This part covers how that works (the mechanism is a lovely hack), the fixture model around it, and the unit-testing story for when you don't want HTTP at all.

The test URL cache

How does a generic method turn CreateOrderEndpoint into a URL? Part 2 planted the seed: during route registration, MapFastEndpoints calls IEndpoint.SetTestUrl(def.EndpointType, finalRoute) for every endpoint — building a static endpoint-type-to-final-route map as a byproduct of real registration. The testing extensions read that map. There is even a hidden endpoint — _test_url_cache_, excluded from swagger — that serves the whole cache as JSON, which is how out-of-process test clients get it.

The typed client then does more than substitute the URL. Route templates like /orders/{id} are filled from the request DTO's matching properties; properties marked [FromHeader] become HTTP headers on the outgoing request; [FromCookie] properties become cookies; sendAsFormData: true converts the DTO to multipart form content for file-upload endpoints. In other words, the test client replays the binder's rules from Part 4 in reverse — the DTO is the contract in both directions. When a test fails, it fails because the contract broke, not because the test's private copy of the wiring drifted.

Responses come back as TestResult<TRes>: the raw HttpResponseMessage for status/header assertions plus the deserialized DTO — and if the response was an error envelope instead, that is surfaced properly rather than as a JSON exception. (A nice sharp default hides here: when deserializing test responses, unmapped JSON members can be configured to fail the test — catching response-contract drift that production clients would silently ignore.)

App lifecycle: fixtures that don't boot per test

Test speed dies on app startup. The package's AppFixture<TProgram> wraps WebApplicationFactory with a deliberate caching policy: one app instance per derived fixture type, per test run — created on first use, shared by every test class using that fixture, torn down at the end. Two different fixture subclasses (say, DefaultApp and AppWithFakeEmail) get two apps; fifty test classes using DefaultApp share one.

public class DefaultApp : AppFixture<Program>
{
    protected override void ConfigureServices(IServiceCollection s)
        => s.AddSingleton<IEmailSender, FakeEmailSender>();
}

public class OrderTests(DefaultApp app) : TestBase<DefaultApp>
{
    [Fact, Priority(1)]
    public async Task Create_then_fetch() { ... }
}

That model has a consequence the docs are upfront about: shared app means shared state, so test ordering sometimes matters (create-then-fetch flows). Instead of pretending otherwise, the package ships an xUnit extension with a [Priority] attribute and test-collection orderers — plus StateFixture for shared state with its own lifecycle. Purists will object that ordered integration tests are a smell; practitioners who have waited on a 40-second-per-class boot loop will recognize the trade. I use ordering sparingly — one ordered collection per aggregate's lifecycle story — and keep everything else independent.

Two more fixture details earned their keep in my projects: ConfigureApp hooks for swapping configuration per fixture, and the fact that fixtures expose pre-configured HttpClients with auth helpers, so “a client logged in as tenant admin” is a fixture property, not twenty lines of token plumbing per test file.

Faking the seams

Because commands (Part 11) are dispatched through a registry, the testing package can substitute handlers without touching DI:

new FakeRefundHandler().RegisterForTesting();   // ICommandHandler<RefundOrder, RefundStatus>

Every new RefundOrder{...}.ExecuteAsync() in the system-under-test now hits the fake. This is the practical payoff of routing cross-feature calls through commands: the seam you created for decoupling is also the seam you stub. Events have a mirror: test event receivers can be registered so a test can assert “publishing happened” without real subscribers running.

Unit tests: no HTTP at all

For handler logic, the Factory class fabricates endpoint instances directly:

var ep = Factory.Create<CreateOrderEndpoint>(ctx =>
{
    ctx.AddTestServices(s => s.AddSingleton(fakeOrderService));
    ctx.Request.Headers["x-tenant"] = "t1";
});

await ep.HandleAsync(new() { CustomerId = "c-42" }, default);

ep.Response.OrderId.ShouldNotBe(Guid.Empty);

Factory.Create builds the endpoint with a DefaultHttpContext you can shape, wires property injection, and — the detail that makes it viable — the endpoint's Response property is readable afterwards even if the handler used Send.* methods, because the send path stores the response object on the endpoint before serialization. Mappers and validators are settable from outside (remember their public setters with the //access is public to support testing comments from earlier parts — the codebase consistently spends API surface on testability).

My division of labour: unit tests via Factory.Create for branchy handler logic (fast, no server); route-less integration tests for contract, binding, validation and auth behaviour (the things unit tests cannot see — Part 7's policies run in middleware, so only HTTP-level tests exercise them); and a thin smoke layer against real infrastructure. The testing package is opinionated toward the middle tier, and that is the correct opinion: most API bugs live in the space between “handler logic” and “wire contract”, which is exactly the space route-less typed tests cover.

One caveat to note: the whole model leans on xUnit (the custom test framework, ordering, fixtures). NUnit/MSTest shops can still use the HttpClient extensions — the URL cache works with any client — but lose the fixture and ordering machinery.

The last mechanism part of the series goes below runtime entirely. Everything so far — scanning, compiled setters, policy building — leans on reflection, and Native AOT forbids most of it. The way FastEndpoints squares that circle is a set of Roslyn source generators that move discovery and reflection to compile time: Part 15, Trading Reflection for Roslyn.

Which CV would you like?

Pick the one closest to the role. Each is two pages and carries the same facts.

Applied AI

Agentic AI & Cloud Engineer · multi-agent systems, RAG and MCP on AWS

Cloud & .NET

C#, .NET 10 and ASP.NET Core microservices on AWS and Azure · Terraform & AWS CDK

Data Engineering

AWS lakehouse · Kinesis, Glue (PySpark), Apache Iceberg, Lake Formation

Everything

All three domains, with the standout project from each