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 inDisposeAsync. Pair with xUnitIClassFixture/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
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:
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
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
✅ Correct: pin
❌ Wrong: forgetting cleanup
✅ Correct: dispose in IAsyncLifetime
❌ Wrong: hardcoded port
✅ 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
- ⚠️
:latesttag — 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.