Skip to content
Kumar Chandrachooda
.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 11 May 2026 5 min read
Tests reach endpoints by type, not by route string

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.