Skip to content

Testcontainers for .NET

Key Points

  • Testcontainers spins up real Docker containers (Postgres, MySQL, Redis, Kafka, RabbitMQ, MongoDB, Azurite, LocalStack, ...) for integration tests. Real engine; high fidelity.
  • Modules ship as NuGet packages: Testcontainers.PostgreSql, Testcontainers.MsSql, Testcontainers.Redis, etc.
  • Fixture lifecycle: start in IAsyncLifetime.InitializeAsync, stop in DisposeAsync. Pair with xUnit IClassFixture / ICollectionFixture.
  • Performance: container startup is ~1–10s. Reuse fixtures (collection fixture) and use Respawn for fast row reset.
  • CI: works on GitHub Actions, Azure DevOps, GitLab. Requires Docker socket. Mac/Windows runners need Docker Desktop alternative (Colima, Rancher Desktop).

Concepts (deep dive)

Basic usage

public class DbFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithDatabase("testdb")
        .WithUsername("test")
        .WithPassword("test")
        .Build();

    public string ConnectionString => _container.GetConnectionString();

    public Task InitializeAsync() => _container.StartAsync();
    public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}

public class RepositoryTests : IClassFixture<DbFixture>
{
    private readonly DbFixture _fx;
    public RepositoryTests(DbFixture fx) => _fx = fx;

    [Fact]
    public async Task Insert_persists()
    {
        await using var ctx = new AppDb(new DbContextOptionsBuilder<AppDb>()
            .UseNpgsql(_fx.ConnectionString).Options);
        await ctx.Database.MigrateAsync();
        // ...
    }
}

Available modules (selection)

Module NuGet package
Postgres Testcontainers.PostgreSql
SQL Server Testcontainers.MsSql
MySQL Testcontainers.MySql
Redis Testcontainers.Redis
Mongo Testcontainers.MongoDb
RabbitMQ Testcontainers.RabbitMq
Kafka Testcontainers.Kafka
Elasticsearch Testcontainers.Elasticsearch
Azure Storage (Azurite) Testcontainers.Azurite
Localstack (AWS) Testcontainers.LocalStack
Cosmos DB Emulator Testcontainers.CosmosDb
Generic Testcontainers (raw)

Generic (custom) container

var container = new ContainerBuilder()
    .WithImage("nginx:alpine")
    .WithPortBinding(8080, 80)
    .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(80)))
    .Build();

Wait strategies

Wait.ForUnixContainer()
    .UntilPortIsAvailable(5432)
    .UntilCommandIsCompleted("pg_isready")
    .UntilHttpRequestIsSucceeded(r => r.ForPath("/health"))
    .UntilMessageIsLogged("ready to accept connections")
    .UntilFileExists("/tmp/ready");

Built-in modules already configure proper waits; custom containers need explicit ones.

Composing multiple containers

var network = new NetworkBuilder().Build();

var db = new PostgreSqlBuilder().WithNetwork(network).WithNetworkAliases("db").Build();
var app = new ContainerBuilder()
    .WithImage("myapp:test")
    .WithNetwork(network)
    .WithEnvironment("ConnectionStrings__Default", "Host=db;...")
    .DependsOn(db)
    .Build();

await network.CreateAsync();
await db.StartAsync();
await app.StartAsync();

For compose-style multi-service tests.

Combining with WebApplicationFactory

public class CustomFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _pg = new PostgreSqlBuilder().Build();

    public async Task InitializeAsync() => await _pg.StartAsync();

    protected override void ConfigureWebHost(IWebHostBuilder b)
    {
        b.ConfigureTestServices(s =>
        {
            s.RemoveAll<DbContextOptions<AppDb>>();
            s.AddDbContext<AppDb>(o => o.UseNpgsql(_pg.GetConnectionString()));
        });
    }

    public new Task DisposeAsync() => _pg.DisposeAsync().AsTask();
}

Respawn for fast reset

private Respawner _respawner;

public async Task InitializeAsync()
{
    await _container.StartAsync();
    _respawner = await Respawner.CreateAsync(_container.GetConnectionString(),
        new RespawnerOptions { DbAdapter = DbAdapter.Postgres });
}

public Task ResetAsync() => _respawner.ResetAsync(_container.GetConnectionString());

Truncates tables; faster than recreating the container.

Reusing containers across runs

new PostgreSqlBuilder()
    .WithReuse(true)             // experimental
    .WithLabel("project", "myapp")
    .Build();

Containers persist across test sessions — speeds up local iteration.

Resource cleanup

Testcontainers uses Ryuk — a sidecar container that watches for the test process and tears down everything when it exits. Even on crash, no orphan containers.

CI considerations

GitHub Actions:

runs-on: ubuntu-latest    # has docker preinstalled

Azure DevOps: docker preinstalled on ubuntu-latest agents.

Docker rate limits: Docker Hub anonymous pulls limited. Use a registry pull-through cache (GHCR, ACR) or auth.

MacOS in CI: GitHub macOS runners don't have Docker — use Linux for Testcontainer tests.

Performance tuning

  • Reuse containers with collection fixture (one DB, many test classes).
  • Pre-pull images in CI before test step.
  • Respawn between tests instead of dropping/creating.
  • Parallelize collections that don't share resources.
  • Use Alpine-based images when possible — faster pulls.

A typical setup: one Postgres container per test run, Respawn between tests, ~50ms per reset, ~5–10s startup. Fine for hundreds of tests.

Versioning containers

.WithImage("postgres:16-alpine")

Pin to specific version. Latest can change unexpectedly.

Migrations in tests

public async Task InitializeAsync()
{
    await _container.StartAsync();
    await using var ctx = new AppDb(/* options */);
    await ctx.Database.MigrateAsync();
    /* seed reference data */
}

Run actual EF migrations in test setup. Catches migration bugs at test time.

Local quirks

  • Docker Desktop license cost may push teams to Rancher Desktop or Colima (Mac) or Podman (Linux). Testcontainers supports them.
  • WSL2 on Windows: ensure Docker is configured for WSL integration; tests in WSL need WSL Docker socket.

Testcontainers Cloud

Optional commercial service: runs containers in remote Docker hosts so tests don't load local resources. Useful for big suites; not free.


Code: correct vs wrong

❌ Wrong: latest tag

new PostgreSqlBuilder().WithImage("postgres").Build();   // floats

✅ Correct: pin

new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();

❌ Wrong: forgetting cleanup

_container.StartAsync();   // never disposed → orphan container if Ryuk fails

✅ Correct: dispose in IAsyncLifetime

public Task DisposeAsync() => _container.DisposeAsync().AsTask();

❌ Wrong: hardcoded port

.WithPortBinding(5432)    // collides with local Postgres

✅ Correct: random host port

.Build();   // default: random host port; getter exposes mapped port
var connStr = _container.GetConnectionString();

Design patterns for this topic

Pattern 1 — "Module per dependency"

  • Intent: use the official module (Postgres, Redis) over generic.

Pattern 2 — "Collection fixture for many tests"

  • Intent: one container per collection; Respawn between tests.

Pattern 3 — "Migrations in InitializeAsync"

  • Intent: test real schema, not snapshots.

Pattern 4 — "Combined factory + container"

  • Intent: integration test the full pipeline against real DB.

Pattern 5 — "Pinned image versions"

  • Intent: reproducibility.

Pros & cons / trade-offs

Aspect Pros Cons
Real engine High fidelity Slower than in-memory
Module ecosystem Many DBs/services Some only via generic
Ryuk cleanup No orphans Extra sidecar container
Reuse Fast Test pollution risk
CI support Linux runners Mac/Windows pain

When to use / when to avoid

  • Use for repository/DAL tests that need real SQL.
  • Use for queue/event tests (Kafka, RabbitMQ).
  • Avoid for pure unit tests — too slow.
  • Avoid in environments without Docker.

Interview Q&A

Q1. Why prefer Testcontainers over EF in-memory? Real engine catches actual SQL bugs (concurrency, JSONB, indexing, raw SQL). EF in-memory diverges from real providers.

Q2. What's Ryuk? Sidecar container that watches the test process. On exit, tears down test containers. Prevents orphans on crash.

Q3. Lifecycle hooks in xUnit? IAsyncLifetime.InitializeAsync (start) and DisposeAsync (stop). Used in IClassFixture / ICollectionFixture.

Q4. Reset DB between tests? Respawn truncates tables — faster than container restart. Or transaction-per-test rollback.

Q5. Can Testcontainers reuse images across runs? Yes — .WithReuse(true) keeps containers alive. Good for local iteration.

Q6. How handle Docker rate limits in CI? Use a pull-through cache (ACR, GHCR) or authenticated Docker Hub pulls.

Q7. CI on Mac runners? GitHub macOS runners don't have Docker. Use Linux for tests; macOS only for platform-specific tests.

Q8. Combining with WebApplicationFactory? Factory inherits IAsyncLifetime; starts container in InitializeAsync; configures EF with container conn string in ConfigureTestServices.

Q9. Wait strategies? Tells the builder when the container is ready. Modules ship sensible defaults; custom containers need explicit waits (port, log, http, file).

Q10. Why random host ports? Avoid collisions with local services. GetConnectionString() returns the mapped port.

Q11. Pin image versions? Yes — :latest floats; tests become non-reproducible. Pin to specific version (postgres:16-alpine).

Q12. Testcontainers vs Docker Compose for tests? Testcontainers is programmatic (one process spins everything); Compose is declarative (separate docker compose up). Testcontainers integrates with test lifecycle better.


Gotchas / common mistakes

  • ⚠️ :latest tag — non-reproducible.
  • ⚠️ Hardcoded ports — collisions.
  • ⚠️ Forgetting cleanup — orphan containers (Ryuk usually catches).
  • ⚠️ Per-test container — too slow; reuse via collection fixture.
  • ⚠️ Docker Hub rate limits — auth or cache.
  • ⚠️ Tests run on macOS CI without Docker — runner mismatch.

Further reading