API Testing with WebApplicationFactory in ASP.NET Core
Between
unit tests and full browser-driven
end-to-end tests sits a very productive middle layer: tests that spin up your real ASP.NET Core pipeline (routing, model binding, middleware, filters, DI, authentication) in the same process as the test, and drive it through an in-memory HttpClient. No Kestrel, no socket, no browser. Just your app, running for real, in milliseconds.
WebApplicationFactory<TEntryPoint> was introduced in ASP.NET Core 2.1 in 2018 as part of the Microsoft.AspNetCore.Mvc.Testing package. It replaced a decade of hand-rolled solutions (TestServer directly, custom host builders, startup tricks) with one clean primitive. With .NET 6’s minimal APIs and the top-level Program.cs, the story got even simpler. If you have read the previous article on
integration testing with TestContainers, you already have the database story. This article covers the HTTP story on top of it.
Why this pattern exists #
Picture a team whose API has 40 endpoints. They have great unit tests for handlers and repositories, but the bugs they keep finding in production are:
- A filter that accidentally skipped authorization on one route.
- A model binder that silently coerced a null enum to
0. - A Problem Details response that changed shape after a middleware upgrade.
- A route collision between
/orders/{id}and/orders/export.
None of these bugs live in a single class. They live in the pipeline: the interaction between routing, filters, DI, and serialization. Unit tests cannot see them. Running the app in CI and curling it works but is slow and fragile. What the team actually needs:
- The real pipeline, not a simulation, so routing and filters behave as they will in production.
- Fast startup, so a test run covers 200 endpoints in under a minute.
- Service override hooks, so one test can swap a dependency (the payment gateway, the clock) without touching production code.
WebApplicationFactory gives you all three.
Overview: how it plugs in #
in-memory] C --> D[Your Program.cs
DI, middleware, endpoints] D --> E[HttpClient] A --> E D --> F[(Postgres from TestContainers)]
The factory boots your Program.cs with a TestServer instead of Kestrel. The HttpClient it hands back talks to the pipeline directly, skipping the network. Everything you care about (routing, filters, auth, serialization) runs for real.
💡 Info :
WebApplicationFactory<TEntryPoint>uses a type argument that points at any type in your startup assembly. The convention isWebApplicationFactory<Program>. If you use top-level statements, you need to addpublic partial class Program { }at the bottom ofProgram.csso the test project can reference the type.
The invisible HTTP pipeline you are actually testing #
When an endpoint is declared as a minimal API or a controller action, ASP.NET Core does a surprising amount of work between “an HTTP request arrived” and “your handler runs with C# arguments”. Most of that work is invisible in the source code, which is exactly why bugs hide there. A real WebApplicationFactory test exercises all of it at once:
- Route matching and constraints:
{id:guid}rejects a non-GUID and returns 404 before the handler ever runs. - Model binding from the query string:
?status=active&page=2is parsed into typed parameters, including nullable types, enums, and arrays. - Model binding from the body: the JSON body is deserialized into a DTO via
System.Text.Json, applying any custom converters, naming policies, or numeric formats configured inJsonSerializerOptions. - Header binding:
[FromHeader]parameters,Acceptnegotiation,If-None-Match,Authorizationall feed the pipeline. - Form binding and file uploads:
multipart/form-datais split into fields andIFormFileinstances. - Model validation: data annotations and
IValidatableObjectfire, and validation failures return aValidationProblemDetailsresponse without the handler being called. - Content negotiation and output serialization: the C# return value is converted back to JSON, Problem Details, or any other registered formatter, with the right
Content-Typeandcharset. - Status code selection:
Results.Ok(...),Results.NotFound(),TypedResults.NoContent(), and unhandled exceptions are translated into proper HTTP status codes.
None of this is written in your endpoint file. All of it runs for real in a WebApplicationFactory test. When a test fails because a query string parameter did not bind, a JSON property was renamed by a naming policy, a date format changed, or a validator now rejects a previously-valid payload, the failure is telling you something the source code alone cannot: the contract between HTTP and C# has shifted. That is exactly the category of bugs unit tests cannot catch, and it is the reason WebApplicationFactory earns its place in the pyramid.
Zoom: the minimum test #
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net.Http.Json;
using Xunit;
public class OrderEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrderEndpointsTests(WebApplicationFactory<Program> factory)
=> _client = factory.CreateClient();
[Fact]
public async Task GET_orders_returns_200_with_list()
{
var response = await _client.GetAsync("/orders");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var orders = await response.Content.ReadFromJsonAsync<List<OrderDto>>();
orders.Should().NotBeNull();
}
}
Five lines of setup, the rest is an actual HTTP assertion. factory.CreateClient() returns an HttpClient pre-wired to the test server. No ports, no hostname, no real socket.
✅ Good practice : Assert on status codes and response bodies, not on internal state. A good API test should be replaceable with a curl command that proves the same behavior. Internal coupling makes tests brittle.
Zoom: overriding services #
The most valuable feature of WebApplicationFactory is ConfigureWebHost, where you can replace any service registered in production. Stripe payment gateway? Swap for a fake. System clock? Inject a FakeTimeProvider.
public class TestAppFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IPaymentGateway>();
services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(new FakeTimeProvider(
DateTimeOffset.Parse("2026-04-08T12:00:00Z")));
});
}
}
public sealed class FakePaymentGateway : IPaymentGateway
{
public Task<ChargeResult> ChargeAsync(CustomerId c, Money m, CancellationToken ct)
=> Task.FromResult(new ChargeResult(Success: true));
}
Then consume it in tests:
public class SubmitOrderTests : IClassFixture<TestAppFactory>
{
private readonly TestAppFactory _factory;
public SubmitOrderTests(TestAppFactory factory) => _factory = factory;
[Fact]
public async Task POST_submit_charges_and_returns_204()
{
var client = _factory.CreateClient();
var response = await client.PostAsync($"/orders/{Guid.NewGuid()}/submit", null);
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
}
💡 Info :
services.RemoveAll<T>()comes fromMicrosoft.Extensions.DependencyInjection.Extensions. It is the idiomatic way to override a registration instead of appending a second one.
❌ Never do this : Do not use
Mock.Setup(...)to fake behavior insideConfigureServices. Mocks belong in unit tests. For integration tests, a small hand-writtenFake*class is easier to read and survives refactors better.
Zoom: combining with TestContainers #
The real power is combining WebApplicationFactory with a Postgres container. Your tests drive the real pipeline against the real database. This is where 80% of the bugs actually hide.
public class ApiWithDbFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine").Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<ShopDbContext>>();
services.AddDbContext<ShopDbContext>(o =>
o.UseNpgsql(_db.GetConnectionString()));
});
}
public async ValueTask InitializeAsync()
{
await _db.StartAsync();
using var scope = Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<ShopDbContext>();
await ctx.Database.MigrateAsync();
}
public new async ValueTask DisposeAsync()
{
await _db.DisposeAsync();
await base.DisposeAsync();
}
}
One fixture. Real pipeline. Real database. Tests that POST a command, commit a row, and verify the GET endpoint returns it. If an EF Core mapping is wrong, a filter forgets to run, or a middleware mangles the response body, this kind of test catches it.
✅ Good practice : Seed your test data through the API whenever possible, not by inserting rows into the database directly. Tests that do
POST /ordersthenGET /orders/{id}prove the whole flow works end to end. Tests that bypass the API prove only the pieces you remembered to exercise.
Zoom: authentication in tests #
Real APIs are protected. You have two clean options:
1. Test authentication handler : register a fake scheme that authenticates every request as a test user.
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
services.PostConfigure<AuthenticationOptions>(o =>
{
o.DefaultAuthenticateScheme = "Test";
o.DefaultChallengeScheme = "Test";
});
TestAuthHandler just builds a ClaimsPrincipal from a configured test user. Simple, fast, deterministic.
2. Real JWT flow : have the test call your actual /token endpoint with a test account, grab the token, attach it to the HttpClient. Slower but proves the auth flow works too.
⚠️ It works, but… : Option 1 is fine for most tests, but keep at least one test per auth-protected route that goes through option 2. Otherwise, the day your real JWT setup breaks, no test will notice.
When not to use it #
WebApplicationFactory is effective, but it remains in-process. If your production system depends on behavior that only shows up with real network, multiple processes, or a real reverse proxy (sticky sessions, SignalR over WebSockets through Nginx, client certificate auth), you will need a full E2E setup on top. That is the topic of the next article.
Wrap-up #
You now know how to drive your real ASP.NET Core pipeline from tests: create a WebApplicationFactory<Program>, use ConfigureWebHost to swap production dependencies for fakes, combine it with a Postgres container for full integration coverage, and add a test authentication handler so your protected routes are reachable. You can write tests that post a command and verify state via a second request, proving the whole pipeline from routing to persistence works end to end.
Ready to level up your next project or share it with your team? See you in the next one, End-to-End testing with Playwright is where we go next.
Related articles #
- Unit Testing in .NET: Fast, Focused, and Actually Useful
- Integration Testing with TestContainers for .NET