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.

Key takeaways
  • WebApplicationFactory<TEntryPoint> boots your real app in-process, giving tests a real HttpClient against 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

csharp
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

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

csharp
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));
    }
}
Keeping the suite fastShare the Testcontainers instance across all tests in a collection instead of starting a fresh container per test — container startup dominates test suite runtime otherwise. Reset data between tests with a transaction rollback or a targeted cleanup query, not a full container restart.

Need help with this in production?

astrivo helps teams design, build, and modernize systems like this. Let's talk about your context.