Integration testing in .NET with Testcontainers and WebApplicationFactory
Integration testing in .NET with Testcontainers and WebApplicationFactory runs your API against real PostgreSQL, so constraints and query counts get tested.
The suite is green. Four hundred tests, six seconds. The deploy goes out, and the first customer to submit an order number that already exists gets a 500 instead of the 409 the handler was written to return. The unique index that raises the error exists in production and in every migration. It does not exist in the tests, because the tests run against EF Core's in-memory provider, and the in-memory provider does not have unique indexes.
Nobody was careless. The provider was chosen because it is fast and needs no setup, and every test that touched the database passed. They passed against a database that does not exist.
Where the in-memory provider lies
The provider is a dictionary with a LINQ front end. That is fine for a demo and hostile to a test suite, because the things a relational database does that your code depends on are exactly the things it leaves out:
- Constraints. Unique indexes, foreign keys, and check constraints are not enforced. A duplicate, an orphan, or a negative quantity saves happily.
- SQL. Nothing is translated. A LINQ expression PostgreSQL cannot translate passes in the test
and throws in production.
string.Containsis case-sensitive in memory and follows the column collation in the database. - Transactions.
BeginTransactionthrows until you suppress the transaction warning, after which it is silently ignored. Either way, every test of a rollback path is fiction. - Migrations. The schema is whatever the model says today. The migration you forgot to add never fails.
- Query counts. There are no commands, so there is nothing to count. An N+1 is invisible.
EF Core's own testing guidance recommends testing against the production database system and describes the in-memory provider as a poor fit. The fix is not a better fake. It is the real database, started for the test run.
Where the test stands
Which failures each tier can see
Unit test with a fake
microseconds, no I/O
- Business rule in C# catches it
- Unique constraint violation does not look
- LINQ with no SQL translation does not look
- N+1 query count does not look
- Transaction rollback path does not look
- Missing migration does not look
EF Core in-memory provider
milliseconds, no Docker
- Business rule in C# catches it
- Unique constraint violation passes but lies
- LINQ with no SQL translation passes but lies
- N+1 query count passes but lies
- Transaction rollback path passes but lies
- Missing migration passes but lies
PostgreSQL in a container
seconds once, then milliseconds per test
- Business rule in C# catches it
- Unique constraint violation catches it
- LINQ with no SQL translation catches it
- N+1 query count catches it
- Transaction rollback path catches it
- Missing migration catches it
A test that passes against a database that does not exist has not tested the database. It has tested your imagination of it.
One container per test run
Testcontainers for .NET starts a Docker container from test
code and removes it when the run ends. Combined with WebApplicationFactory<Program> from
Microsoft.AspNetCore.Mvc.Testing, the API boots in-process against a real PostgreSQL, and only
the connection string changes:
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Testcontainers.PostgreSql;
public sealed class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.Build();
public QueryCounter Queries { get; } = new();
public string ConnectionString => _postgres.GetConnectionString();
public async Task InitializeAsync()
{
await _postgres.StartAsync();
using var scope = Services.CreateScope();
await scope.ServiceProvider
.GetRequiredService<AppDbContext>()
.Database.MigrateAsync();
}
public new async Task DisposeAsync()
{
await base.DisposeAsync();
await _postgres.DisposeAsync();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options => options
.UseNpgsql(_postgres.GetConnectionString())
.AddInterceptors(Queries));
});
}
}
Three details matter. The container starts before Services is touched, because building the host
runs ConfigureWebHost, which reads the connection string. RemoveAll drops the app's
DbContextOptions registration so the new one is not ignored; AddDbContext uses try-add
semantics. And migrations run once, so the tests exercise the schema production has. The API
project needs public partial class Program { } at the end of Program.cs so the factory can see
the entry point, and the factory as written targets xUnit 2; xUnit 3 moves IAsyncLifetime to
ValueTask.
Share the factory with IClassFixture<ApiFactory> for one class or a collection fixture across
many. The container starts once either way. xUnit runs test classes in parallel by default, so
classes that share tables belong in one collection, where they run serially. If that serializes
too much, give each class its own database inside the same container: a CREATE DATABASE costs
well under a second and avoids a second container.
Reset state, do not rebuild it
Tests that share a database need a clean start without paying for a new container. Wrapping each
test in a transaction and rolling it back is the cheapest option, and it does not work across the
HTTP boundary: the request runs in its own scope on its own connection. For API tests, delete
instead. Respawn computes the delete order from the foreign keys and clears every table in one
round trip, and TRUNCATE ... CASCADE on the tables you seeded is the hand-rolled version. Either
runs in a few milliseconds per test:
await using var connection = new NpgsqlConnection(factory.ConnectionString);
await connection.OpenAsync();
var respawner = await Respawner.CreateAsync(connection, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
TablesToIgnore = new Table[] { "__EFMigrationsHistory" }
});
await respawner.ResetAsync(connection);
Ignoring the migrations table matters: reset it and the next MigrateAsync tries to recreate a
schema that already exists.
Test the behaviors only a real database has
The query counter is a DbCommandInterceptor, the same hook EF Core uses for logging:
public sealed class QueryCounter : DbCommandInterceptor
{
private int _count;
public int Count => Volatile.Read(ref _count);
public void Reset() => Interlocked.Exchange(ref _count, 0);
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _count);
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}
Now the tests can ask questions the in-memory provider could never answer:
public sealed class OrdersApiTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
[Fact]
public async Task Duplicate_order_number_is_rejected_by_the_database()
{
var client = factory.CreateClient();
var order = new { Number = "ORD-1001", CustomerId = Guid.NewGuid() };
var first = await client.PostAsJsonAsync("/orders", order);
var second = await client.PostAsJsonAsync("/orders", order);
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
Assert.Equal(1, await db.Orders.CountAsync(o => o.Number == "ORD-1001"));
}
[Fact]
public async Task Order_summary_page_issues_one_query()
{
var client = factory.CreateClient();
await SeedOrdersAsync(factory, count: 40);
factory.Queries.Reset();
var response = await client.GetAsync("/orders/summary");
response.EnsureSuccessStatusCode();
Assert.Equal(1, factory.Queries.Count);
}
}
The first test is the production bug. The second request hits the unique index, the handler maps
the 23505 unique-violation error to a 409, and the database confirms one row exists. The second
test is an N+1 tripwire: a summary page over 40 orders that issues 41
queries fails here, weeks before it fails on a Tuesday. Add a NonQueryExecutingAsync override to
the counter and you can also assert that a bulk update sent one statement instead of fifty thousand.
A third test can post an order whose second line violates a check constraint and assert that
neither line nor the order exists afterwards, which is the rollback path the in-memory provider
ignored.
What it costs, honestly
Put numbers on the trade. Say the container takes 3 seconds to report ready and the migrations take 1 second. If a test averages 25 milliseconds, 200 tests cost 5 seconds of test time on top of 4 seconds of setup: 9 seconds for the suite. The first run on a fresh machine also pulls the image, which is a one-time download. Those are example figures and your suite is the real benchmark, but the shape holds: setup is paid once per run, and each test is milliseconds, not the seconds people expect from "a real database".
CI needs a Docker socket. GitHub-hosted Linux runners have one; a build that itself runs inside a
container needs the host socket mounted or a Docker-in-Docker sidecar. Testcontainers reads
DOCKER_HOST and works with either.
Keep the boundary honest
None of this replaces unit tests. Domain logic that decides whether an order can be cancelled should run in microseconds against no I/O, and unit testing best practices covers how to keep those fast and honest. Integration tests belong at the boundary: the request in, the SQL out, the row that comes back. That boundary is where constraints, translations, and transactions live, which makes it where the bugs the in-memory provider hides live too.
Katabench is built on the same premise. Every database puzzle in the EF Core track runs your LINQ against real PostgreSQL and grades it on the plan the database chose, and the Test Writing track scores the tests you write on the planted bugs they catch, the way mutation testing does, rather than on whether they went green. The grading model explains both. If you want the operational version, the Production Outbox Lab runs PostgreSQL and RabbitMQ for real and hands you crashes to recover from, which is what the boundary looks like when it is on fire.