Testing ASP.NET Core applications

WebApplicationFactory runs the real pipeline in memory, which catches the wiring bugs a unit test cannot see.

An in-memory host

using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

public sealed class ApiFactory : WebApplicationFactory<Program>
{
    private readonly string _connectionString;

    public ApiFactory(string connectionString) => _connectionString = connectionString;

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");
        builder.ConfigureAppConfiguration((_, config) =>
        {
            config.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["ConnectionStrings:App"] = _connectionString,
                ["RateLimit:RequestsPerMinute"] = "1000",
            });
        });

        builder.ConfigureServices(services =>
        {
            // Replace a real dependency with a deterministic one
            services.RemoveAll<IClock>();
            services.AddSingleton<IClock>(new FixedClock(
                new DateTimeOffset(2026, 9, 18, 9, 0, 0, TimeSpan.Zero)));

            services.RemoveAll<IRatesClient>();
            services.AddSingleton<IRatesClient>(new StubRatesClient(1.27m));

            // Build the provider once so startup failures surface here
            using var provider = services.BuildServiceProvider();
            using var scope = provider.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            db.Database.Migrate();
        });
    }
}

public sealed class OrderEndpointsTests : IClassFixture<ApiFactory>
{
    private readonly ApiFactory _factory;
    public OrderEndpointsTests(ApiFactory factory) => _factory = factory;

    [Fact]
    public async Task Missing_field_returns_a_validation_problem()
    {
        var client = _factory.CreateClient();

        var response = await client.PostAsJsonAsync("/api/orders",
            new { qty = 2 });            // sku omitted

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        var problem = await response.Content
            .ReadFromJsonAsync<ValidationProblemDetails>();
        Assert.Contains("Sku", problem!.Errors.Keys);
    }

    [Fact]
    public async Task Protected_endpoint_challenges_an_anonymous_caller()
    {
        var client = _factory.CreateClient();
        var response = await client.GetAsync("/api/orders/summary");
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }
}
  • CreateClient follows redirects by default. Turn that off when testing a redirect response, or the assertion sees the final destination instead of the 302.
  • The test host runs the real middleware pipeline, so this is where CORS, authentication and exception handler bugs are caught.
  • Overriding a service is the correct way to remove a dependency. Registering your stub after the real one does not replace it.
  • A real database via Testcontainers is worth the seconds it costs when the query itself is what you are testing.

Testing behind authentication

// A test authentication scheme that trusts a header, used only in tests
public sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public const string Scheme = "Test";

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue("X-Test-User", out var user))
            return Task.FromResult(AuthenticateResult.NoResult());

        var claims = new[]
        {
            new Claim("sub", user.ToString()),
            new Claim("scope", Request.Headers["X-Test-Scope"].ToString()),
            new Claim("tenant", Request.Headers["X-Test-Tenant"].ToString()),
        };

        var identity = new ClaimsIdentity(claims, Scheme);
        var principal = new ClaimsPrincipal(identity);
        return Task.FromResult(AuthenticateResult.Success(
            new AuthenticationTicket(principal, Scheme)));
    }
}

// In the factory, after the real schemes are registered
services.AddAuthentication(TestAuthHandler.Scheme)
        .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
            TestAuthHandler.Scheme, _ => { });

// In a test
client.DefaultRequestHeaders.Add("X-Test-User", "u-1");
client.DefaultRequestHeaders.Add("X-Test-Scope", "orders:write");
client.DefaultRequestHeaders.Add("X-Test-Tenant", "eu");

// The most valuable tests are the negative ones
[Fact]
public async Task A_user_from_another_tenant_cannot_read_the_order()
{
    var client = _factory.CreateClient();
    client.DefaultRequestHeaders.Add("X-Test-User", "u-2");
    client.DefaultRequestHeaders.Add("X-Test-Tenant", "us");

    var response = await client.GetAsync("/api/orders/1042");
    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
⚠️
Test the negative path with the same care as the happy path. Almost every production incident involving authorisation passed a test suite that only proved a permitted user could get in.

FAQ

Why does Program need to be partial for the factory?
WebApplicationFactory discovers the entry point through a type in the application assembly. Adding a partial Program declaration in a top-level-statements project gives the test project something to reference.
Should integration tests share one database?
Preferably not. Give each collection its own schema or container, and reset the data between tests, or failures will depend on execution order.

Error handling, health checks and resilience Authentication and authorisation

Last refreshed 2026-09-18.