Integration Testing with TestContainers for .NET: PostgreSQL, Redis, and WebApplicationFactory
Integration tests that mock the database are not integration tests. They are unit tests with extra ceremony. The moment you swap your real PostgreSQL for an in-memory SQLite, you stop testing the thing that breaks in production: your actual queries, your migrations, your transaction isolation, your Redis eviction behavior. TestContainers fixes this by spinning up real containers per test run, letting your code hit actual infrastructure that gets thrown away when the test suite finishes.
This post walks through setting up TestContainers for .NET with xUnit, covering PostgreSQL with EF Core, Redis for cache testing, WebApplicationFactory integration, parallel test isolation, Respawn for fast database resets, and running the whole thing on GitHub Actions.
Why TestContainers Over Mocking
The standard objections to integration tests are speed and flakiness. Mocking solves both by removing infrastructure entirely — and that is precisely the problem. Consider:
Moq-based repository fakes don't test that your LINQ query translates to valid SQL- In-memory EF Core doesn't enforce foreign key constraints or trigger-based defaults
IDistributedCachemocks don't validate your serialization assumptions or TTL logic- SQLite doesn't support
RETURNING, array operators, or most PostgreSQL-specific features
TestContainers trades 30–60 seconds of container startup for confidence that your code works against real infrastructure. On a CI runner with a warm Docker layer cache, PostgreSQL 15 starts in under 15 seconds. That's a fair trade.
Project Setup
Add the NuGet packages:
<PackageReference Include="Testcontainers" Version="3.8.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.8.0" />
<PackageReference Include="Testcontainers.Redis" Version="3.8.0" />
<PackageReference Include="Respawn" Version="6.2.1" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />TestContainers talks to Docker via the Docker socket. On Linux/Mac it resolves automatically. On Windows with Docker Desktop, it resolves through npipe://./pipe/docker_engine. No additional configuration needed for local development.
PostgreSQL Container with EF Core Migrations
The correct pattern is a class fixture that owns the container lifecycle. One container per test class, shared across all tests in that class — not one per test method.
public class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine")
.WithDatabase("testdb")
.WithUsername("testuser")
.WithPassword("testpass")
.WithCleanUp(true)
.Build();
public string ConnectionString => _container.GetConnectionString();
public Respawner Respawner { get; private set; } = null!;
public async Task InitializeAsync()
{
await _container.StartAsync();
// Run EF Core migrations against the real container
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(ConnectionString)
.Options;
await using var context = new AppDbContext(options);
await context.Database.MigrateAsync();
// Configure Respawn after migrations so it knows the schema
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
Respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
SchemasToInclude = ["public"],
TablesToIgnore = [new Table("__EFMigrationsHistory")]
});
}
public async Task DisposeAsync() => await _container.DisposeAsync();
}Notice WithCleanUp(true) — this registers a Ryuk container that removes your test containers even if the process dies unexpectedly. It's enabled by default but worth being explicit.
Now wire the fixture into your test class:
public class OrderRepositoryTests : IClassFixture<PostgresFixture>, IAsyncLifetime
{
private readonly PostgresFixture _fixture;
private AppDbContext _context = null!;
public OrderRepositoryTests(PostgresFixture fixture)
{
_fixture = fixture;
}
public async Task InitializeAsync()
{
// Reset DB state before each test using Respawn
await using var conn = new NpgsqlConnection(_fixture.ConnectionString);
await conn.OpenAsync();
await _fixture.Respawner.ResetAsync(conn);
_context = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_fixture.ConnectionString)
.Options);
}
public async Task DisposeAsync() => await _context.DisposeAsync();
[Fact]
public async Task CreateOrder_PersistsWithCorrectTotals()
{
var repo = new OrderRepository(_context);
var order = new Order
{
CustomerId = Guid.NewGuid(),
Lines = [new OrderLine { ProductId = 1, Quantity = 3, UnitPrice = 9.99m }]
};
await repo.CreateAsync(order);
await _context.SaveChangesAsync();
var loaded = await _context.Orders
.Include(o => o.Lines)
.FirstAsync(o => o.Id == order.Id);
Assert.Equal(29.97m, loaded.Total);
Assert.Single(loaded.Lines);
}
}Respawn is doing the heavy work here. Rather than dropping and recreating the database between tests (which would require re-running migrations, costing several seconds), Respawn issues targeted DELETE or TRUNCATE statements in the correct foreign-key order. A full reset on a 20-table schema takes under 100ms.
Redis Container for Cache Testing
public class RedisFixture : IAsyncLifetime
{
private readonly RedisContainer _container = new RedisBuilder()
.WithImage("redis:7-alpine")
.WithCleanUp(true)
.Build();
public string ConnectionString => _container.GetConnectionString();
public IConnectionMultiplexer Multiplexer { get; private set; } = null!;
public async Task InitializeAsync()
{
await _container.StartAsync();
Multiplexer = await ConnectionMultiplexer.ConnectAsync(ConnectionString);
}
public async Task DisposeAsync()
{
await Multiplexer.CloseAsync();
await _container.DisposeAsync();
}
}
public class SessionCacheTests : IClassFixture<RedisFixture>
{
private readonly IDatabase _db;
public SessionCacheTests(RedisFixture fixture)
{
_db = fixture.Multiplexer.GetDatabase();
}
[Fact]
public async Task UserSession_ExpiresAfterTtl()
{
var cache = new SessionCache(_db);
var sessionId = Guid.NewGuid().ToString();
await cache.StoreAsync(sessionId, new UserSession { UserId = 42 }, ttl: TimeSpan.FromSeconds(1));
var found = await cache.GetAsync(sessionId);
Assert.NotNull(found);
await Task.Delay(TimeSpan.FromSeconds(1.5));
var expired = await cache.GetAsync(sessionId);
Assert.Null(expired);
}
[Fact]
public async Task CartCache_UpdatesQuantityInPlace()
{
var cache = new CartCache(_db);
await cache.AddItemAsync("cart:user:7", productId: 101, quantity: 2);
await cache.AddItemAsync("cart:user:7", productId: 101, quantity: 3);
var cart = await cache.GetAsync("cart:user:7");
Assert.Equal(5, cart.Items.First(i => i.ProductId == 101).Quantity);
}
}This tests actual TTL behavior, actual atomic increments, actual serialization. A mocked IDistributedCache would make both of these tests trivially pass regardless of whether your implementation is correct.
WebApplicationFactory + TestContainers
The most valuable pattern is combining TestContainers with WebApplicationFactory<TProgram> to test the full HTTP stack — routing, middleware, authentication, request validation — against real infrastructure.
public class IntegrationTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine")
.WithCleanUp(true)
.Build();
private readonly RedisContainer _redis = new RedisBuilder()
.WithImage("redis:7-alpine")
.WithCleanUp(true)
.Build();
public Respawner Respawner { get; private set; } = null!;
public string DbConnectionString => _postgres.GetConnectionString();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Replace production DB connection with test container
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(opts =>
opts.UseNpgsql(_postgres.GetConnectionString()));
// Replace production Redis with test container
services.RemoveAll<IConnectionMultiplexer>();
services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(_redis.GetConnectionString()));
});
}
public async Task InitializeAsync()
{
await Task.WhenAll(_postgres.StartAsync(), _redis.StartAsync());
await using var scope = Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await context.Database.MigrateAsync();
await using var conn = new NpgsqlConnection(_postgres.GetConnectionString());
await conn.OpenAsync();
Respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
TablesToIgnore = [new Table("__EFMigrationsHistory")]
});
}
public new async Task DisposeAsync()
{
await _postgres.DisposeAsync();
await _redis.DisposeAsync();
}
}Note Task.WhenAll for starting both containers in parallel — this cuts startup time nearly in half.
Using the factory in tests:
public class OrdersApiTests : IClassFixture<IntegrationTestFactory>, IAsyncLifetime
{
private readonly IntegrationTestFactory _factory;
private readonly HttpClient _client;
public OrdersApiTests(IntegrationTestFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
public async Task InitializeAsync()
{
await using var conn = new NpgsqlConnection(_factory.DbConnectionString);
await conn.OpenAsync();
await _factory.Respawner.ResetAsync(conn);
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task POST_Order_Returns201_AndPersistsToDb()
{
var payload = new { CustomerId = Guid.NewGuid(), Items = new[] { new { ProductId = 1, Quantity = 2 } } };
var response = await _client.PostAsJsonAsync("/api/orders", payload);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<OrderDto>();
Assert.NotNull(created?.Id);
// Verify it actually hit the database, not just returned from an in-memory store
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var order = await db.Orders.FindAsync(created!.Id);
Assert.NotNull(order);
Assert.Equal(payload.CustomerId, order!.CustomerId);
}
}Parallel Test Execution with Isolated Containers
xUnit runs test classes in parallel by default. If multiple test classes share one container, you'll get connection pool contention and data collision. The solution is per-class fixtures — each test class gets its own container instance.
For high-volume test suites, creating a container per class is expensive. The right trade-off is to use collection fixtures to group related test classes under one shared container:
[CollectionDefinition("Orders")]
public class OrdersCollection : ICollectionFixture<IntegrationTestFactory> { }
[Collection("Orders")]
public class OrdersApiTests { /* ... */ }
[Collection("Orders")]
public class OrderDraftTests { /* ... */ }
// Separate collection = separate container, runs in parallel with Orders
[CollectionDefinition("Payments")]
public class PaymentsCollection : ICollectionFixture<IntegrationTestFactory> { }
[Collection("Payments")]
public class PaymentsApiTests { /* ... */ }This gives you:
OrdersandPaymentscollections run in parallel (separate containers, no contention)- Test classes within the same collection run sequentially, sharing one container
- Respawn handles data isolation between tests within a collection
If you need fully parallel test-level isolation — each test gets a fresh schema — use PostgreSQL schemas instead of databases:
public async Task<string> CreateIsolatedSchema()
{
var schemaName = $"test_{Guid.NewGuid():N}";
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync($"CREATE SCHEMA {schemaName}");
// Run migrations targeting this schema
return schemaName;
}This is faster than creating a new database but heavier than Respawn — use it only when tests genuinely need concurrent writes.
GitHub Actions: Docker-in-Docker and Hosted Runners
GitHub Actions hosted runners (ubuntu-latest) have Docker installed and the Docker socket available. No Docker-in-Docker setup is required — TestContainers works out of the box.
name: Integration Tests
on:
push:
branches: [main]
pull_request:
jobs:
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Pull container images
run: |
docker pull postgres:15-alpine
docker pull redis:7-alpine
# Pre-pulling prevents TestContainers from timing out on first pull
# inside the test run where timeout budgets are tighter
- name: Run integration tests
run: dotnet test --filter Category=Integration --logger trx --results-directory TestResults
env:
DOTNET_ENVIRONMENT: Test
# TestContainers reads DOCKER_HOST automatically on ubuntu-latest
# No explicit configuration needed
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: TestResults/*.trxPre-pulling images is the most important optimization on CI. The first docker pull postgres:15-alpine on a cold runner takes 20–40 seconds. If TestContainers triggers this pull inside the test runner's startup sequence, it eats into the container readiness timeout. Pulling explicitly before dotnet test separates the network wait from the readiness wait.
For self-hosted runners or Docker-in-Docker (Kubernetes CI), configure the Docker socket explicitly:
env:
DOCKER_HOST: unix:///var/run/docker.sock
TESTCONTAINERS_RYUK_DISABLED: "false" # Keep cleanup enabledFor Kubernetes-based CI (Tekton, Argo Workflows) where Docker-in-Docker is unavailable, use TESTCONTAINERS_HOST_OVERRIDE to point at a remote Docker daemon, or switch to Podman with DOCKER_HOST=unix:///run/user/1000/podman/podman.sock.
Container Lifecycle Summary
| Scope | Pattern | Use case |
|---|---|---|
| Per test method | IAsyncLifetime on test class |
Full isolation, slow — avoid unless necessary |
| Per test class | IClassFixture<T> + Respawn |
Default choice for most integration tests |
| Per collection | ICollectionFixture<T> + Respawn |
Parallel collections, grouped classes |
| Per assembly | Custom AssemblyFixture (xUnit v3) |
Shared heavy containers, long-running suites |
The default should be IClassFixture with Respawn. Move to collection fixtures when you want parallel container groups. Only drop to per-method containers for tests that mutate schema or test migration logic itself.
Key Takeaways
Use real containers, not fakes. Mocked repositories and in-memory databases hide entire categories of bugs. TestContainers makes the real thing cheap enough to use in CI.
One container per fixture, Respawn for resets. Container startup is a one-time cost per test run. Respawn resets data in milliseconds. Do not drop and recreate schemas between tests.
Parallel execution via collections. xUnit's collection fixtures let you run groups of tests in parallel, each group with its own container. This scales to large suites without data collisions.
Pre-pull images in CI. Separate docker pull from test execution. It avoids timeout races and makes test startup time predictable.
WebApplicationFactory replaces production wiring. Override ConfigureTestServices to swap connection strings, not entire service implementations. Your tests exercise the real DI graph, real middleware, real routing — only the infrastructure endpoints change.
Tag integration tests. Use [Trait("Category", "Integration")] to allow dotnet test --filter Category=Integration in CI and dotnet test --filter Category!=Integration for fast local unit test runs. Engineers run unit tests on every save; integration tests run on push and in PR checks.
TestContainers adds roughly 30–60 seconds to a cold CI run. In exchange, you stop finding PostgreSQL-specific bugs in production and start finding them in pull requests.