Skip to content
Katabench
Try free
8 min read The Katabench team

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.Contains is case-sensitive in memory and follows the column collation in the database.
  • Transactions. BeginTransaction throws 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

same handler, three databases

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
The fake is honest about its scope. The in-memory provider claims to be the database and is not. Test the boundary where the bugs live, on the database that will run 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.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.