Unit tests prove a handler does the right thing when you call it directly. They don't prove routing is wired up correctly, that your DI registrations actually resolve, or that a real database round-trips the query you think it does. WebApplicationFactory runs your actual app — real pipeline, real DI container — in memory, for exactly that gap.
WebApplicationFactory<TEntryPoint>boots your real app in-process, giving tests a realHttpClientagainst your actual routing and middleware.- Swap infrastructure dependencies (the database, external HTTP clients) in
ConfigureTestServices, not the whole DI container. - Use Testcontainers for a real database instance rather than EF Core's in-memory provider — the in-memory provider doesn't enforce real SQL constraints and hides real bugs.
- A test authentication handler that issues a known principal is simpler and faster than running a real OIDC flow in every integration test.
A custom factory
public class ApiTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_db.GetConnectionString()));
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
});
}
public async Task InitializeAsync()
{
await _db.StartAsync();
using var scope = Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.MigrateAsync();
}
public new async Task DisposeAsync() => await _db.DisposeAsync();
}
Testcontainers starts a real Postgres instance in Docker for the test run and tears it down afterward. It's slower to spin up than the EF Core in-memory provider, but it catches things the in-memory provider silently ignores — unique constraints, foreign keys, actual SQL type coercion.
Writing the test
public class OrdersApiTests(ApiTestFactory factory) : IClassFixture<ApiTestFactory>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task SubmitOrder_ReturnsNoContent()
{
var response = await _client.PostAsJsonAsync("/orders/8f14e45f.../submit", new { });
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
}
Faking authentication instead of running a real OIDC flow
Running an actual OpenID Connect challenge against a real (or mocked) identity provider in every integration test is slow and brittle. A test authentication handler that issues a fixed, known principal covers authorization logic without any of that overhead.
public class TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user"), new Claim(ClaimTypes.Role, "Admin") };
var identity = new ClaimsIdentity(claims, "Test");
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}