Skip to main content
SEMastery
Testingintermediate

Testcontainers: Integration Testing Using Docker in .NET

A friendly .NET 10 guide to Testcontainers: spin up real databases in Docker for trustworthy integration tests, with xUnit, WebApplicationFactory, and clean cleanup.

13 min readUpdated June 1, 2026

The food stall on wheels

Imagine your uncle runs a chaat stall. Every evening he needs the same things: a clean table, a fresh gas burner, and a bowl of chutney. If he borrows the neighbour's table, sometimes it is dirty, sometimes it is missing, and sometimes someone else is using it. His chaat tastes different every day, and not because of his cooking.

One day he buys a small cart. The cart has its own table, its own burner, and its own fresh chutney. He wheels it out, sells chaat, and at night he wheels it back and washes everything. Tomorrow it starts clean again. Every single evening is the same fresh start.

Testcontainers is that cart for your tests. Instead of borrowing a shared database that might be dirty or missing, each test run gets its own fresh database inside a Docker container. It starts clean, your test uses it, and then it is thrown away. No mess left behind, and the same fresh start every time.

This guide shows you how to use that idea in .NET 10, step by step, in plain language.

Why not just use a fake database?

Many people start by mocking the database or using the EF Core in-memory provider. It is fast and easy. But it is also a little bit of a lie.

The in-memory provider is not a real database. It does not run real SQL. It ignores some foreign keys. It does not behave like PostgreSQL or SQL Server. So your test can pass even when your real app would break in production. That is the worst kind of test: one that gives you false confidence.

Fake DB vs real DB in tests

Write code
Test with fake DB
Test passes
Ship to prod
Real DB breaks

Steps

1

Write code

SQL with a constraint

2

Test with fake DB

constraint ignored

3

Test passes

false confidence

4

Ship to prod

real DB used

5

Real DB breaks

bug found by users

A fake database can hide bugs that a real one catches.

Testcontainers fixes this by giving you the real database in a container. Your test talks to the same kind of database you use in production. If it passes the test, you can actually trust it.

Here is the difference at a glance.

ApproachReal SQL?Same as prod?Setup neededTrust level
Mock / fakeNoNoNoneLow
EF in-memoryNoNoNoneLow
Shared dev DBYesMaybeLotsMedium
TestcontainersYesYesJust DockerHigh

How Testcontainers works under the hood

The idea is simple. Testcontainers is a .NET library that talks to your local Docker engine. You tell it "I want a PostgreSQL 17 container," and it asks Docker to start one. Docker pulls the image (the first time only), runs it, and gives you back a connection string. Your test uses that connection string. When you are done, the container is stopped and removed.

The full Testcontainers flow: from asking for a container to cleaning it up.

One nice detail: Testcontainers picks a random free port on your machine and maps it to the database inside the container. This means two test runs can happen at the same time without fighting over the same port. It just works.

There is also a small helper container called Ryuk. Think of it as a cleaner who watches the door. When your test session ends, even if your tests crash, Ryuk makes sure the containers get removed. So you never end up with a hundred forgotten containers eating your disk space.

Setting up your project

First, you need Docker running. On a laptop this is Docker Desktop. On a build server it is a Docker daemon. Once Docker is there, the rest is just NuGet packages.

For a project testing a PostgreSQL-backed app with xUnit, install these:

dotnet add package Testcontainers.PostgreSql
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package xunit
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL

The Testcontainers.PostgreSql package is a ready-made module. Testcontainers ships modules for many services: PostgreSQL, SQL Server, MySQL, Redis, RabbitMQ, MongoDB, Kafka, and more. Each one knows the right image and the right way to wait until the service is actually ready.

Your first container test

Let's start with the smallest possible example. We will start a PostgreSQL container, connect to it, and run one query. No web app yet, just the database.

using Testcontainers.PostgreSql;
using Npgsql;
using Xunit;
 
public class DatabaseTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container =
        new PostgreSqlBuilder()
            .WithImage("postgres:17-alpine")
            .WithDatabase("shop")
            .WithUsername("postgres")
            .WithPassword("postgres")
            .Build();
 
    // Runs once before the tests in this class.
    public Task InitializeAsync() => _container.StartAsync();
 
    // Runs once after the tests finish.
    public Task DisposeAsync() => _container.DisposeAsync().AsTask();
 
    [Fact]
    public async Task Database_Can_Run_A_Simple_Query()
    {
        var connectionString = _container.GetConnectionString();
 
        await using var connection = new NpgsqlConnection(connectionString);
        await connection.OpenAsync();
 
        await using var command = new NpgsqlCommand("SELECT 1", connection);
        var result = await command.ExecuteScalarAsync();
 
        Assert.Equal(1, result);
    }
}

Let's read this slowly, because every line matters.

The IAsyncLifetime interface from xUnit gives us two methods. InitializeAsync runs before the tests and starts the container. DisposeAsync runs after and cleans it up. This is the key pattern: start once, clean up once.

GetConnectionString() gives you a ready-to-use connection string pointing at the container, with the random port already filled in. You never hard-code a port. The builder methods like WithDatabase and WithPassword set up the database name and login.

Sharing one container across many tests

Starting a container takes a second or two. If every test started its own container, a suite of 50 tests would be painfully slow. The fix is to start one container and share it.

In xUnit, you do this with a fixture. A fixture is a shared object that xUnit creates once and hands to every test in a collection.

Shared container lifecycle

Start container
Run test 1
Run test 2
Run test N
Remove container

Steps

1

Start container

once, at the start

2

Run test 1

reuse container

3

Run test 2

reuse container

4

Run test N

reuse container

5

Remove container

once, at the end

One container is started once, used by many tests, then removed once.

Here is the fixture. It looks almost the same as before, but now it is a separate shared class.

using Testcontainers.PostgreSql;
using Xunit;
 
public class PostgresFixture : IAsyncLifetime
{
    public PostgreSqlContainer Container { get; } =
        new PostgreSqlBuilder()
            .WithImage("postgres:17-alpine")
            .WithDatabase("shop")
            .Build();
 
    public Task InitializeAsync() => Container.StartAsync();
 
    public Task DisposeAsync() => Container.DisposeAsync().AsTask();
}
 
[CollectionDefinition("postgres")]
public class PostgresCollection : ICollectionFixture<PostgresFixture> { }

Now any test class marked with [Collection("postgres")] will share the same running container. xUnit starts it once before the first test and removes it after the last one. Many tests, one cart.

Testing a real web API end to end

The real power shows up when you combine Testcontainers with WebApplicationFactory. This class boots your whole ASP.NET Core app in memory and gives you an HttpClient. You point your app at the test container's database, send real HTTP requests, and check the responses.

The trick is to swap your app's database connection string for the container's one before the app starts. You do that by overriding ConfigureWebHost.

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;
using Xunit;
 
public class ShopApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _container =
        new PostgreSqlBuilder()
            .WithImage("postgres:17-alpine")
            .Build();
 
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Remove the app's real DbContext registration.
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<ShopDbContext>));
            if (descriptor is not null)
                services.Remove(descriptor);
 
            // Point the app at the test container instead.
            services.AddDbContext<ShopDbContext>(options =>
                options.UseNpgsql(_container.GetConnectionString()));
        });
    }
 
    public async Task InitializeAsync()
    {
        await _container.StartAsync();
 
        // Create the schema inside the fresh container.
        using var scope = Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<ShopDbContext>();
        await db.Database.MigrateAsync();
    }
 
    public new Task DisposeAsync() => _container.DisposeAsync().AsTask();
}

Now a test can use this factory to hit the real API:

[Collection("shop-api")]
public class ProductEndpointTests
{
    private readonly HttpClient _client;
 
    public ProductEndpointTests(ShopApiFactory factory)
    {
        _client = factory.CreateClient();
    }
 
    [Fact]
    public async Task Posting_A_Product_Then_Getting_It_Returns_The_Product()
    {
        var create = await _client.PostAsJsonAsync(
            "/products", new { name = "Masala Chai", price = 20 });
 
        create.EnsureSuccessStatusCode();
 
        var response = await _client.GetAsync("/products");
        var products = await response.Content
            .ReadFromJsonAsync<List<Product>>();
 
        Assert.Contains(products!, p => p.Name == "Masala Chai");
    }
}

This test is honest. It starts your real app, sends a real HTTP request, runs real SQL against a real PostgreSQL, and reads the result back. If your route, your validation, your EF mapping, or your SQL is wrong, this test catches it. A note on routes: a path like GET /products/{id} is filled in by your handler at runtime, so always test it with a real id value, not a literal placeholder.

Keeping tests clean from each other

There is one trap to watch for. If many tests share one container, they also share one database. Test A might add a row that test B then sees by accident. This makes tests flaky and order-dependent, which is a nightmare to debug.

You have a few ways to keep each test in its own clean state.

StrategyHow it worksBest for
RespawnDeletes all rows between testsMost projects
Unique dataEach test uses its own keysRead-heavy tests
New container per classFresh DB per test classStrong isolation
Transaction rollbackWrap test in a rolled-back transactionSimple data layers

The most popular choice is Respawn, a small library that wipes all the data between tests while keeping the schema. You run it after each test, and the next test starts on a clean table without paying for a whole new container.

Resetting state between tests keeps them independent.

The golden rule is simple: never let one test depend on data left behind by another. Each test should set up what it needs and not assume anything else.

Running this in CI

A big reason people love Testcontainers is that it works the same everywhere. Your laptop and the CI server run the exact same database version, because both pull the same Docker image. The old excuse "but it works on my machine" finally goes away.

On most CI systems, like GitHub Actions, the Linux runners already have Docker installed. You usually do not need any special setup. Your dotnet test command just works, because Testcontainers finds the Docker engine automatically.

Same tests, everywhere

Push code
CI pulls image
Container starts
Tests run
Pass or fail

Steps

1

Push code

to the repo

2

CI pulls image

same postgres tag

3

Container starts

via Docker

4

Tests run

real DB

5

Pass or fail

matches laptop

Laptop and CI use the same image, so results match.

A few tips for smooth CI runs. Always pin your image tag, like postgres:17-alpine, instead of latest, so the version never changes unexpectedly. Reuse one container across a collection to keep runs fast. And let Ryuk handle cleanup so the runner does not fill up with leftover containers.

Beyond databases

Testcontainers is not only for databases. Any service that ships as a Docker image can be a container in your test. This is huge for testing apps that talk to many systems.

// A Redis cache for testing.
var redis = new RedisBuilder()
    .WithImage("redis:7-alpine")
    .Build();
 
// A RabbitMQ broker for testing messaging.
var rabbit = new RabbitMqBuilder()
    .WithImage("rabbitmq:3-management")
    .Build();
 
await redis.StartAsync();
await rabbit.StartAsync();

So if your app uses PostgreSQL for data, Redis for caching, and RabbitMQ for messages, you can spin up all three in containers and test the whole thing together. This is as close to production as a test can get without actually being production.

Your app under test, talking to several real containers at once.

A quick note on libraries: some popular .NET messaging libraries like MassTransit and the MediatR family now use a commercial license for many uses. Testcontainers itself stays free and open source, but always check the license of any extra library you add to your project.

A simple mental model

When you feel unsure, come back to the chaat cart. The cart is the container. It arrives clean, you use it, you wash it, it leaves. Your test gets a fresh real database, uses it, and Testcontainers throws it away. Nothing borrowed, nothing left dirty, the same fresh start every time.

That single idea is the whole reason Testcontainers makes tests trustworthy. You are no longer guessing how a fake database behaves. You are watching the real thing work.

Quick recap

  • Testcontainers starts real services in Docker just for your tests, then throws them away. It is like a fresh chaat cart every evening.
  • Fake databases can lie. The EF in-memory provider does not run real SQL. A real container behaves like production, so passing tests actually mean something.
  • You need Docker running, plus a NuGet module like Testcontainers.PostgreSql. After that, everything is code.
  • Use IAsyncLifetime with InitializeAsync to start the container and DisposeAsync to clean it up.
  • Share one container across a test collection with a fixture, so you pay the startup cost once, not per test.
  • Combine with WebApplicationFactory to test your whole API end to end against a real database.
  • Keep tests independent. Reset state between tests with Respawn or unique data so one test never leaks into another.
  • CI just works because the same Docker image runs everywhere, killing the "works on my machine" problem.
  • Ryuk cleans up leftover containers automatically, even if your tests crash.

References and further reading

Related Posts