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.
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.