Testing .NET Minimal APIs: WebApplicationFactory, Auth, and TestContainers
.NET Minimal APIs strip away the controller ceremony and put your endpoint logic directly in delegates. That's a win for readability, but it shifts how you think about testing. Controllers gave you a natural seam — test the controller class, mock its dependencies. Minimal APIs push you toward two distinct strategies: pure function unit tests for simple handlers, and WebApplicationFactory-based integration tests when you need the full pipeline. This guide covers both, plus authentication, middleware, and real database testing with TestContainers.
Minimal API Structure and Testability
A minimal API app in .NET 6+ looks like this:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IProductRepository, ProductRepository>();
var app = builder.Build();
app.MapGet("/products/{id}", async (int id, IProductRepository repo) =>
{
var product = await repo.GetByIdAsync(id);
return product is null ? Results.NotFound() : Results.Ok(product);
});
app.Run();The handler is a delegate — and delegates are just functions. That's the first testability lever. When your handler has no side effects beyond its injected dependencies, you can test it as a pure function without spinning up the full HTTP pipeline.
The second lever is WebApplicationFactory<T>. For integration tests that exercise routing, middleware, authentication, and real DB behavior, you create a test host that runs your actual app with substituted dependencies.
Unit Testing Endpoint Delegates
Extract handlers into static methods or into dedicated handler classes. This makes them independently testable without any HTTP context.
// ProductHandlers.cs
public static class ProductHandlers
{
public static async Task<IResult> GetProduct(
int id,
IProductRepository repo)
{
var product = await repo.GetByIdAsync(id);
return product is null ? Results.NotFound() : Results.Ok(product);
}
public static async Task<IResult> CreateProduct(
CreateProductRequest request,
IProductRepository repo,
IValidator<CreateProductRequest> validator)
{
var validation = await validator.ValidateAsync(request);
if (!validation.IsValid)
return Results.ValidationProblem(validation.ToDictionary());
var product = await repo.CreateAsync(request);
return Results.Created($"/products/{product.Id}", product);
}
}Register in Program.cs:
app.MapGet("/products/{id}", ProductHandlers.GetProduct);
app.MapPost("/products", ProductHandlers.CreateProduct);Now unit tests are straightforward with xUnit and NSubstitute:
public class ProductHandlersTests
{
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
[Fact]
public async Task GetProduct_ReturnsOk_WhenProductExists()
{
var product = new Product { Id = 1, Name = "Widget", Price = 9.99m };
_repo.GetByIdAsync(1).Returns(product);
var result = await ProductHandlers.GetProduct(1, _repo);
var okResult = Assert.IsType<Ok<Product>>(result);
Assert.Equal(product.Id, okResult.Value!.Id);
}
[Fact]
public async Task GetProduct_ReturnsNotFound_WhenMissing()
{
_repo.GetByIdAsync(99).Returns((Product?)null);
var result = await ProductHandlers.GetProduct(99, _repo);
Assert.IsType<NotFound>(result);
}
[Fact]
public async Task CreateProduct_ReturnsValidationProblem_WhenInvalid()
{
var validator = Substitute.For<IValidator<CreateProductRequest>>();
validator.ValidateAsync(Arg.Any<CreateProductRequest>())
.Returns(new ValidationResult(new[] {
new ValidationFailure("Name", "Name is required")
}));
var result = await ProductHandlers.CreateProduct(
new CreateProductRequest { Name = "" },
_repo,
validator);
Assert.IsType<ValidationProblem>(result);
}
}These tests run in milliseconds. No HTTP, no DI container, no database. When you can push logic into extractable handlers, this is your fastest feedback loop.
WebApplicationFactory for Integration Tests
For tests that need routing, middleware execution, and the full request pipeline, use WebApplicationFactory. Minimal APIs require you to make Program accessible — the cleanest way is a partial class declaration:
// Program.cs (add at the bottom)
public partial class Program { }Then your test project:
public class ProductsIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ProductsIntegrationTests(WebApplicationFactory<Program> factory)
{
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace real repository with an in-memory fake
services.RemoveAll<IProductRepository>();
services.AddSingleton<IProductRepository, InMemoryProductRepository>();
});
}).CreateClient();
}
[Fact]
public async Task GetProduct_Returns200_WithValidId()
{
var response = await _client.GetAsync("/products/1");
response.EnsureSuccessStatusCode();
var product = await response.Content.ReadFromJsonAsync<Product>();
Assert.NotNull(product);
Assert.Equal(1, product.Id);
}
[Fact]
public async Task GetProduct_Returns404_ForMissingProduct()
{
var response = await _client.GetAsync("/products/9999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}For test projects that share a single factory instance across multiple test classes, implement IAsyncLifetime and manage setup/teardown explicitly:
public class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
public async Task InitializeAsync()
{
// seed data, start containers, etc.
}
public new async Task DisposeAsync()
{
await base.DisposeAsync();
}
}Testing Auth with Custom Authorization Handlers
Testing authorization in minimal APIs means both "can the right user access this?" and "does the wrong user get blocked?". The trick is replacing your auth schemes in the test host without gutting the authorization logic itself.
// In production
app.MapDelete("/products/{id}", ProductHandlers.DeleteProduct)
.RequireAuthorization("AdminOnly");For tests, inject a fake authentication handler:
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.Name, "TestUser"),
new Claim(ClaimTypes.Role, "Admin"),
new Claim("sub", "test-user-id")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}Wire it into your factory:
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
});
}).CreateClient();For testing unauthorized access, create a separate client that returns 401:
public class UnauthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
// ...
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
=> Task.FromResult(AuthenticateResult.NoResult());
}[Fact]
public async Task DeleteProduct_Returns401_WithoutAuth()
{
var response = await _unauthenticatedClient.DeleteAsync("/products/1");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task DeleteProduct_Returns200_WithAdminRole()
{
var response = await _adminClient.DeleteAsync("/products/1");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}For policy-based authorization, test the policy logic separately from the HTTP layer using IAuthorizationService directly:
[Fact]
public async Task AdminOnly_Policy_DeniesNonAdmins()
{
var authService = _serviceProvider.GetRequiredService<IAuthorizationService>();
var user = new ClaimsPrincipal(new ClaimsIdentity(new[] {
new Claim(ClaimTypes.Role, "User") // not Admin
}, "Test"));
var result = await authService.AuthorizeAsync(user, null, "AdminOnly");
Assert.False(result.Succeeded);
}Middleware Testing in Minimal APIs
Middleware that runs before your endpoints (rate limiting, request logging, error handling) needs integration tests — unit testing middleware in isolation is rarely worth the effort given how tightly it's coupled to HttpContext.
Here's the pattern for testing custom middleware:
// ErrorHandlingMiddleware.cs
public class ErrorHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ErrorHandlingMiddleware> _logger;
public ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (NotFoundException ex)
{
context.Response.StatusCode = 404;
await context.Response.WriteAsJsonAsync(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new { error = "Internal server error" });
}
}
}Test it by triggering the exception through the real HTTP pipeline:
[Fact]
public async Task ErrorMiddleware_Returns500_OnUnhandledException()
{
// Arrange: configure repo to throw
_fakeRepo.GetByIdAsync(Arg.Any<int>()).ThrowsAsync(new InvalidOperationException("DB down"));
// Act
var response = await _client.GetAsync("/products/1");
// Assert
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Internal server error", body.GetProperty("error").GetString());
}TestContainers for PostgreSQL and SQL Server
For tests that hit a real database, TestContainers for .NET spins up a Docker container during test execution and tears it down after. This is the cleanest way to verify your EF Core queries, stored procedures, or raw SQL without mocking the database layer.
Install the packages:
dotnet add package Testcontainers.PostgreSql
dotnet add package Microsoft.EntityFrameworkCore.ToolsCreate a fixture that owns the container lifecycle:
public class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("testdb")
.WithUsername("test")
.WithPassword("test")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async Task InitializeAsync()
{
await _container.StartAsync();
// Apply migrations
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(ConnectionString)
.Options;
await using var context = new AppDbContext(options);
await context.Database.MigrateAsync();
}
public async Task DisposeAsync()
=> await _container.DisposeAsync();
}Use it with WebApplicationFactory:
public class ProductsDatabaseTests : IClassFixture<PostgresFixture>, IAsyncLifetime
{
private readonly PostgresFixture _postgres;
private readonly HttpClient _client;
private Respawner _respawner = null!;
public ProductsDatabaseTests(PostgresFixture postgres)
{
_postgres = postgres;
_client = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(opts =>
opts.UseNpgsql(_postgres.ConnectionString));
});
})
.CreateClient();
}
public async Task InitializeAsync()
{
// Respawn resets the DB to a clean state between tests
await using var conn = new NpgsqlConnection(_postgres.ConnectionString);
await conn.OpenAsync();
_respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
SchemasToInclude = new[] { "public" }
});
}
public async Task DisposeAsync()
{
await using var conn = new NpgsqlConnection(_postgres.ConnectionString);
await conn.OpenAsync();
await _respawner.ResetAsync(conn);
}
[Fact]
public async Task CreateProduct_PersistsToDatabase()
{
var request = new CreateProductRequest { Name = "Widget", Price = 9.99m };
var response = await _client.PostAsJsonAsync("/products", request);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<Product>();
// Verify it's actually in the DB
var fetched = await _client.GetAsync($"/products/{created!.Id}");
fetched.EnsureSuccessStatusCode();
var product = await fetched.Content.ReadFromJsonAsync<Product>();
Assert.Equal("Widget", product!.Name);
}
[Fact]
public async Task GetProducts_ReturnsPaginatedResults()
{
// Seed 25 products directly via EF
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_postgres.ConnectionString).Options;
await using var ctx = new AppDbContext(options);
ctx.Products.AddRange(Enumerable.Range(1, 25)
.Select(i => new Product { Name = $"Product {i}", Price = i * 1.0m }));
await ctx.SaveChangesAsync();
var response = await _client.GetAsync("/products?page=1&pageSize=10");
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PagedResult<Product>>();
Assert.Equal(10, page!.Items.Count);
Assert.Equal(25, page.TotalCount);
}
}Test Data Setup with Respawn for DB Cleanup
Respawn is the correct tool for resetting database state between tests. The naive approach — wrapping each test in a transaction and rolling back — breaks when your code opens its own connections or uses TransactionScope. Respawn deletes rows in dependency order by inspecting foreign keys, so your constraints stay intact.
dotnet add package RespawnFor SQL Server, the setup is nearly identical:
_respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
TablesToIgnore = new Table[] { "__EFMigrationsHistory" }
});Key patterns:
- Create the Respawner once per fixture, not per test — the schema introspection is expensive
- Reset in
DisposeAsync(orIAsyncLifetime.DisposeAsync), notInitializeAsync— reset after each test so failures leave the DB in a state you can inspect - Use
TablesToIgnoreto skip reference/lookup tables you seed once at migration time
Key Takeaways
Unit test what you can extract. Moving handler logic into static methods or dedicated classes gives you fast, isolated tests with no HTTP overhead. This should be your first instinct for business logic inside endpoints.
WebApplicationFactory is the integration test backbone. Replace infrastructure dependencies (repos, external HTTP clients, DB contexts) in ConfigureServices, keep the authorization and middleware pipelines real. Test the shape of responses — status codes, headers, body structure — not internal implementation.
Don't mock authentication, replace the scheme. Swapping in a TestAuthHandler lets your actual authorization policies run against controlled claim sets. This tests the real policy logic without needing valid JWTs.
TestContainers over SQLite for DB tests. SQLite doesn't support all SQL Server or PostgreSQL features. Running the real database engine in Docker via TestContainers gives you accurate query behavior, constraint enforcement, and migration validation. The startup cost (2–5 seconds per test session) is worth it.
Respawn over transactions for cleanup. Explicit resets via Respawn are more reliable than transaction rollback, especially when your application code manages its own transaction scope.
Organize tests by layer. Keep unit tests (handler delegates, validators, domain logic) in one project, integration tests (WebApplicationFactory + TestContainers) in another. Unit tests run on every save; integration tests run on CI or before push. The distinction matters for feedback speed.
Minimal APIs reduce boilerplate but not the need for tests. The testing surface is the same as with controllers — you're just working at the delegate level instead of the class level.