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.
The food-stall on wheels
Think about a street food stall in your city. A good one is set up fresh in the morning. The vendor wheels in a clean cart, lights the stove, cooks all day, and at night packs everything away. The next morning, a fresh clean cart appears again. Nothing from yesterday is left lying around.
Now imagine if the vendor never cleaned up. Old onions from Monday, half-cooked rice from Tuesday, a sticky pan from Wednesday. By Friday nobody could trust the food. You would never know if today's stomach ache came from today's lunch or from Monday's leftovers.
Integration testing has the same problem. If your tests reuse an old, dirty database, you can never trust the result. A test might pass only because some leftover row from an earlier test was sitting there. Testcontainers is the fresh cart on wheels. Before your tests run, it wheels in a clean, real database inside a Docker container. After your tests finish, it packs everything away. Every test run starts from a known, clean state.
In this guide you will learn how to use Testcontainers well in .NET 10, the patterns that keep tests fast, and the small mistakes that quietly break test suites.
What problem does Testcontainers actually solve?
When you write tests, you have to decide what your code talks to. A unit test talks to nothing real. An integration test talks to a real database, a real cache, or a real message queue.
For years, people faked the database with an in-memory provider because real databases were slow and annoying to set up. But fakes lie. A fake database does not check foreign keys the same way. It does not run the same SQL. So a test could pass against the fake and fail in production.
Testcontainers fixes this by starting the real thing in a container, just for the test run.
The key idea: your test never knows the messy details. It just asks the fixture for a connection string and gets one. Testcontainers handles pulling the image, starting the container, waiting until it is ready, and tearing it down.
Your first container
Here is a small fixture that starts a PostgreSQL container. We use xUnit and the Testcontainers.PostgreSql package. The IAsyncLifetime interface gives us an async start (InitializeAsync) and an async stop (DisposeAsync).
using Testcontainers.PostgreSql;
using Xunit;
public sealed class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.WithDatabase("shop")
.WithUsername("test")
.WithPassword("test")
.Build();
public string ConnectionString => _container.GetConnectionString();
// Runs once before the tests in this fixture.
public Task InitializeAsync() => _container.StartAsync();
// Runs once after the tests finish. Cleans up the container.
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}Notice three good habits already baked in:
- We pin the image to a real tag (
postgres:17-alpine), notlatest. This keeps tests stable over time. - We never set a fixed host port.
GetConnectionString()reads the random mapped port for us. - We let
DisposeAsyncclean up. We do not leave containers running.
Lifecycle: when does the container start and stop?
This is the part people get wrong most often. Containers take a few seconds to start. If you start a fresh container for every single test method, a suite of 300 tests becomes painfully slow. If you share one container for everything, you save time but you must clean data between tests.
Here is the lifecycle of a shared fixture.
Shared container lifecycle
Steps
Pull image
Docker fetches postgres if not cached
Start container
InitializeAsync runs once
Run tests
All tests share one connection
Reset data
Clean rows between tests
Dispose
Container removed at the end
The diagram below shows the timing as a sequence. The fixture starts the container, then each test borrows it.
Sharing a container across many test classes
A single fixture is shared inside one test class. But often you want many test classes to share the same container, so it only starts once for the whole project. xUnit calls this a collection fixture.
There are three steps.
// 1. Define a collection that uses the fixture.
[CollectionDefinition("postgres")]
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>
{
// This class is empty. It only links the name to the fixture.
}
// 2. Mark each test class with the same collection name.
[Collection("postgres")]
public sealed class OrderTests
{
private readonly PostgresFixture _fixture;
// 3. xUnit injects the shared fixture through the constructor.
public OrderTests(PostgresFixture fixture) => _fixture = fixture;
[Fact]
public async Task Saves_order_to_real_database()
{
await using var db = new ShopDbContext(_fixture.ConnectionString);
db.Orders.Add(new Order("Tea", 12));
await db.SaveChangesAsync();
var count = await db.Orders.CountAsync();
Assert.Equal(1, count);
}
}Now every test class marked [Collection("postgres")] shares one container. The container starts before the first test in the whole collection and stops after the last one.
Keeping tests isolated
Sharing one container is fast but risky. If Test A inserts five orders and Test B counts orders, Test B might see A's rows. Tests must not depend on each other. Each test needs a clean starting point.
You have a few good options to reset state between tests.
| Reset strategy | How it works | Best when |
|---|---|---|
| Respawn | Deletes all rows but keeps the schema | You want fast resets and a stable schema |
| Transaction rollback | Wrap each test in a transaction and roll back | Your code does not commit its own transactions |
| New database per class | Create a fresh DB inside the same container | You need stronger isolation without a new container |
| Recreate schema | Drop and recreate tables each time | Schema is small and you want a guaranteed clean slate |
The most common choice is Respawn. It clears the data between tests without restarting the container, so you keep both speed and a clean slate.
using Respawn;
using Npgsql;
public sealed class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.Build();
private Respawner _respawner = default!;
public string ConnectionString => _container.GetConnectionString();
public async Task InitializeAsync()
{
await _container.StartAsync();
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
_respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres
});
}
// Call this between tests to wipe rows but keep the schema.
public async Task ResetAsync()
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await _respawner.ResetAsync(conn);
}
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}Per-test reset loop
Steps
Arrange
Seed only what this test needs
Act
Call the code under test
Assert
Check the real result
Reset
Respawn wipes rows for the next test
Plugging the container into your ASP.NET Core app
Most real tests do not talk to the database directly. They send HTTP requests to your app, and your app talks to the database. To do this, you combine Testcontainers with WebApplicationFactory. The trick is to override the connection string after the container starts but before the app boots.
public sealed class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Pass the dynamic connection string into the app's config.
builder.UseSetting("ConnectionStrings:Shop", _container.GetConnectionString());
}
public Task InitializeAsync() => _container.StartAsync();
public new Task DisposeAsync() => _container.DisposeAsync().AsTask();
}A common mistake is to hardcode a connection string somewhere. Do not. Testcontainers gives every container a random host port so containers never clash. Always read the port from GetConnectionString() or GetMappedPublicPort().
Common mistakes to avoid
These small slip-ups quietly break test suites. Watch for them.
| Mistake | Why it hurts | Do this instead |
|---|---|---|
| Hardcoding host ports | Two containers fight over the same port | Use the mapped random port |
Using the latest image tag | Tests change when the image changes | Pin a specific version tag |
| Disabling the Resource Reaper | Dead containers pile up and clutter your machine | Leave the Reaper on |
| Starting a container per test method | Suite becomes very slow | Share a container and reset data |
| No readiness wait | Tests hit the database before it is ready | Use a wait strategy or built-in builder |
| Sharing mutable state between tests | Flaky, order-dependent failures | Reset data between tests |
The Resource Reaper deserves a special note. Testcontainers runs a tiny helper container called Ryuk that watches your test process. If your tests crash, Ryuk still cleans up the leftover containers. Turning it off may feel faster, but you will end up with zombie containers eating memory. Leave it on.
Waiting until the service is ready
A container starting is not the same as the service inside it being ready to answer. PostgreSQL needs a moment to accept connections. The official builders, like PostgreSqlBuilder, already include a sensible wait. But for custom images you set your own wait strategy.
var container = new ContainerBuilder()
.WithImage("my-custom-api:1.0")
.WithPortBinding(8080, assignRandomHostPort: true)
// Wait until the service answers on its health endpoint.
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilHttpRequestIsSucceeded(r => r.ForPath("/health").ForPort(8080)))
.Build();This avoids the classic flaky test where the container is up but the app inside is still warming up. Always wait for readiness, not just for the process to start.
Speed tips for big suites and CI
As your suite grows, a few habits keep it quick.
Practical tips:
- Reuse images. Pin and cache your images on CI so Docker does not re-pull every run.
- Run database tests in one collection. This keeps them on one container and avoids parallel clashes.
- Seed the minimum. Insert only the rows a test needs, not a giant fixture for everything.
- Group by dependency. Tests that need Redis go in a Redis collection; tests that need PostgreSQL go in a PostgreSQL collection. Do not start services a test does not use.
- Make CI Docker-ready. Your CI runners must have a Docker engine. Without it, no container can start.
A note on related libraries: some popular .NET packages, including MediatR and MassTransit, moved to a commercial license for newer versions. This does not affect Testcontainers itself, which remains open source, but it is worth checking the license of every package you add to a test project so there are no surprises later.
A simple mental model
If you remember one thing, remember the food cart. A good test environment is fresh, real, and packed away cleanly afterwards. Testcontainers gives you exactly that: a real database, started fresh, torn down at the end, with random ports so nothing clashes.
Start with a shared container for speed. Add a reset step like Respawn for cleanliness. Wire it into WebApplicationFactory so your real app talks to the real database over real HTTP. That combination gives you tests you can actually trust.
Quick recap
- Testcontainers runs real services like PostgreSQL in throwaway Docker containers during your tests.
- A real database catches bugs that the EF Core in-memory provider hides, such as foreign-key and SQL issues.
- Use
IAsyncLifetimeto start the container once and dispose it at the end. - Share one container across many classes with an xUnit collection fixture for speed.
- Keep tests isolated by resetting data between them, often with Respawn.
- Never hardcode ports. Read the random mapped port from
GetConnectionString(). - Pin image tags, leave the Resource Reaper on, and wait for true readiness.
- Combine Testcontainers with
WebApplicationFactoryto test your real app end to end. - Make sure your CI runners have Docker before relying on container-based tests.
References and further reading
- Best Practices — Testcontainers for .NET
- Integration Testing with Testcontainers — Microsoft ISE Developer Blog
- Sharing Context between Tests — xUnit.net
- Testcontainers Best Practices for .NET Integration Testing — Milan Jovanović
- Testing an ASP.NET Core web app — Testcontainers Guides
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.
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.
.NET Aspire Integration Testing: Best Practices for Distributed Apps
Learn .NET Aspire integration testing the simple way. Start your whole app, wait for services, and test how they really work together.
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.
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.
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.