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.
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
Steps
Write code
SQL with a constraint
Test with fake DB
constraint ignored
Test passes
false confidence
Ship to prod
real DB used
Real DB breaks
bug found by users
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.
| Approach | Real SQL? | Same as prod? | Setup needed | Trust level |
|---|---|---|---|---|
| Mock / fake | No | No | None | Low |
| EF in-memory | No | No | None | Low |
| Shared dev DB | Yes | Maybe | Lots | Medium |
| Testcontainers | Yes | Yes | Just Docker | High |
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.
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.PostgreSQLThe 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
Steps
Start container
once, at the start
Run test 1
reuse container
Run test 2
reuse container
Run test N
reuse container
Remove container
once, at the end
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.
| Strategy | How it works | Best for |
|---|---|---|
| Respawn | Deletes all rows between tests | Most projects |
| Unique data | Each test uses its own keys | Read-heavy tests |
| New container per class | Fresh DB per test class | Strong isolation |
| Transaction rollback | Wrap test in a rolled-back transaction | Simple 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.
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
Steps
Push code
to the repo
CI pulls image
same postgres tag
Container starts
via Docker
Tests run
real DB
Pass or fail
matches laptop
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.
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
IAsyncLifetimewithInitializeAsyncto start the container andDisposeAsyncto 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
WebApplicationFactoryto 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
- Testcontainers for .NET — Official Docs
- Getting started with Testcontainers for .NET
- PostgreSQL module — Testcontainers for .NET
- Integration Testing with Testcontainers — Microsoft ISE Developer Blog
- How to use Testcontainers with .NET Unit Tests — JetBrains .NET Tools Blog
- Testcontainers — Integration Testing Using Docker in .NET — Milan Jovanović
Related Posts
ASP.NET Core Integration Testing Best Practices (.NET 10)
A friendly .NET 10 guide to ASP.NET Core integration testing: WebApplicationFactory, real databases with Testcontainers, clean test isolation, and CI tips.
How to Test API Integrations Using WireMock.Net in .NET 10
A beginner-friendly .NET 10 guide to testing API integrations with WireMock.Net: stub HTTP responses, simulate errors and delays, and write reliable tests.
Testcontainers Best Practices for .NET Integration Testing (.NET 10)
A friendly .NET 10 guide to Testcontainers: real databases in Docker, shared container fixtures, clean test isolation, faster CI, and the mistakes to avoid.
Simplify Assertions in Unit and Integration Tests With Verify in .NET
A friendly .NET 10 guide to Verify snapshot testing: cut long assertions, check big objects and JSON, and keep tests clean in xUnit, NUnit, and MSTest.
Testing Modular Monoliths: System Integration Testing in .NET 10
A friendly .NET 10 guide to system integration testing for modular monoliths: test module boundaries, cross-module flows, real databases with Testcontainers.
Creating Data-Driven Tests With xUnit (.NET 10)
A friendly .NET 10 guide to data-driven tests in xUnit: Theory, InlineData, MemberData, ClassData, strongly typed TheoryData, and xUnit v3 tips.