Q25 — Integration Testing with WebApplicationFactory and Testcontainers
Level: Senior | Topic: Testing
Why Integration Tests?
Unit tests verify logic in isolation. Integration tests verify the full ASP.NET Core pipeline — routing, middleware, model binding, EF Core queries — against a real database. WebApplicationFactory spins up the full app in memory. Testcontainers starts a real SQL Server Docker container, eliminating the drift between test and production database behaviour.
Architecture
Use IAsyncLifetime to start/stop the container once per test class. Override ConfigureWebHost to swap the production DbContext with one pointing at the container. Seed test data in ConfigureWebHost so every test class starts from a known state.
Code Example
// Packages: Microsoft.AspNetCore.Mvc.Testing, Testcontainers.SqlEdge, xunit, FluentAssertions
// 1. Container fixture — starts SQL Server once per test class
public class SqlServerFixture : IAsyncLifetime
{
private readonly SqlEdgeContainer _container = new SqlEdgeBuilder()
.WithImage("mcr.microsoft.com/azure-sql-edge:latest")
.Build();
public string ConnectionString => _container.GetConnectionString();
public Task InitializeAsync() => _container.StartAsync();
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}
// 2. Custom WebApplicationFactory — replaces DbContext with test container
public class TestWebAppFactory : WebApplicationFactory<Program>
{
private readonly string _connectionString;
public TestWebAppFactory(string connectionString)
=> _connectionString = connectionString;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove production DbContext registrations
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.RemoveAll<AppDbContext>();
// Register DbContext pointing at the test container
services.AddDbContext<AppDbContext>(opts =>
opts.UseSqlServer(_connectionString));
// Apply migrations + seed test data
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
// Seed deterministic test data here
});
}
}
// 3. Test class — uses real HTTP client against full pipeline
[Collection("SqlServer")]
public class OrdersApiTests : IClassFixture<SqlServerFixture>
{
private readonly HttpClient _client;
public OrdersApiTests(SqlServerFixture fixture)
=> _client = new TestWebAppFactory(fixture.ConnectionString).CreateClient();
[Fact]
public async Task GetOrders_ReturnsOk_WithSeededData()
{
var response = await _client.GetAsync("/api/orders");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var orders = await response.Content.ReadFromJsonAsync<List<OrderDto>>();
orders.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task CreateOrder_WithValidPayload_Returns201WithLocation()
{
var payload = new { CustomerId = "int-test-cust", Total = 149.99m };
var response = await _client.PostAsJsonAsync("/api/orders", payload);
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
var created = await response.Content.ReadFromJsonAsync<OrderDto>();
created!.Total.Should().Be(149.99m);
}
[Fact]
public async Task GetOrder_WithNonExistentId_Returns404WithProblemDetails()
{
var response = await _client.GetAsync($"/api/orders/{Guid.NewGuid()}");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Title.Should().Be("Order not found");
}
}
Senior Insight
Use IClassFixture<SqlServerFixture> so the Docker container starts once per test class (not once per test). Use [Collection("SqlServer")] if multiple test classes share the same container. Never share mutable state between tests — each test should either reset the DB or use unique data. For CI, ensure Docker is available — most GitHub Actions runners have Docker pre-installed. Testcontainers containers are ephemeral and self-cleaning on dispose.