Skip to main content
SEMastery
Testingintermediate

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.

11 min readUpdated February 9, 2026

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.

Where Testcontainers sits between your tests and a real database engine

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:

  1. We pin the image to a real tag (postgres:17-alpine), not latest. This keeps tests stable over time.
  2. We never set a fixed host port. GetConnectionString() reads the random mapped port for us.
  3. We let DisposeAsync clean 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

Pull image
Start container
Run tests
Reset data
Dispose

Steps

1

Pull image

Docker fetches postgres if not cached

2

Start container

InitializeAsync runs once

3

Run tests

All tests share one connection

4

Reset data

Clean rows between tests

5

Dispose

Container removed at the end

One container, started once, reused by many tests, torn down at the end

The diagram below shows the timing as a sequence. The fixture starts the container, then each test borrows it.

Sequence of a shared container fixture across two tests

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.

One collection fixture shared by several test classes

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 strategyHow it worksBest when
RespawnDeletes all rows but keeps the schemaYou want fast resets and a stable schema
Transaction rollbackWrap each test in a transaction and roll backYour code does not commit its own transactions
New database per classCreate a fresh DB inside the same containerYou need stronger isolation without a new container
Recreate schemaDrop and recreate tables each timeSchema 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

Arrange
Act
Assert
Reset

Steps

1

Arrange

Seed only what this test needs

2

Act

Call the code under test

3

Assert

Check the real result

4

Reset

Respawn wipes rows for the next test

How a shared container stays clean for each 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.

MistakeWhy it hurtsDo this instead
Hardcoding host portsTwo containers fight over the same portUse the mapped random port
Using the latest image tagTests change when the image changesPin a specific version tag
Disabling the Resource ReaperDead containers pile up and clutter your machineLeave the Reaper on
Starting a container per test methodSuite becomes very slowShare a container and reset data
No readiness waitTests hit the database before it is readyUse a wait strategy or built-in builder
Sharing mutable state between testsFlaky, order-dependent failuresReset 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.

Decision flow for choosing container scope

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 IAsyncLifetime to 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 WebApplicationFactory to test your real app end to end.
  • Make sure your CI runners have Docker before relying on container-based tests.

References and further reading

Related Posts