.NET & C# Development · Lesson 88 of 92
Testcontainers — Spin Up Postgres & Redis in Your CI Pipeline
The Problem with Mocked Tests
Unit tests with mocked databases are fast, but they lie.
// ❌ This test passes — but is it testing anything real?
var mockRepo = new Mock<IOrderRepository>();
mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(new Order { Id = 1 });
var service = new OrderService(mockRepo.Object);
var result = await service.GetOrderAsync(1);
result.Should().NotBeNull(); // ✅ passes — but you never touched SQL ServerThis test passes even if:
- Your EF Core query has a typo that would fail against a real database
- Your migration has a broken column name
- Your index is missing and the query times out in production
- Your
GROUP BYlogic is wrong
Mocks test your mock, not your code.
Integration tests against a real database catch the bugs that matter. The problem was always: "a real database is slow to set up and tears down messily." Testcontainers solves this.
What Is Testcontainers?
Testcontainers is a library that spins up real Docker containers — SQL Server, PostgreSQL, Redis, RabbitMQ, anything — programmatically from your test code. Each test run gets a fresh, isolated container. No shared state, no leftover data, no "works on my machine" problems.
Test starts
↓
Testcontainers pulls the Docker image (cached after first run)
↓
Container starts (SQL Server / Redis / etc.)
↓
Your test runs against the real service
↓
Container is destroyed after the testThe containers are ephemeral — they exist only for the duration of the test. You get the fidelity of a real database with the isolation of a mock.
Setup
# Install Testcontainers
dotnet add package Testcontainers.MsSql # SQL Server
dotnet add package Testcontainers.PostgreSql # PostgreSQL
dotnet add package Testcontainers.Redis # Redis
dotnet add package Testcontainers.RabbitMq # RabbitMQ
dotnet add package Testcontainers # base package (others depend on it)
# Test framework
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Microsoft.NET.Test.Sdk
dotnet add package FluentAssertions
dotnet add package Microsoft.AspNetCore.Mvc.Testing # for API integration testsRequirements:
- Docker Desktop (Windows/Mac) or Docker Engine (Linux) must be running
- Works on CI — GitHub Actions, Azure DevOps, and most CI runners have Docker available
Your First Container Test
// The simplest possible Testcontainers test — SQL Server
public class SimpleContainerTest : IAsyncLifetime
{
private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder()
.WithPassword("Strong_Passw0rd!")
.Build();
// IAsyncLifetime — start container before tests run
public Task InitializeAsync() => _sqlContainer.StartAsync();
// Stop and remove container after tests finish
public Task DisposeAsync() => _sqlContainer.DisposeAsync().AsTask();
[Fact]
public async Task Can_Connect_And_Query()
{
// Get the connection string — port is dynamically assigned
var connectionString = _sqlContainer.GetConnectionString();
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
var result = await connection.QueryFirstAsync<int>("SELECT 1");
result.Should().Be(1);
}
}The container starts on a random available port — no port conflicts between parallel test runs or CI agents.
Integration Testing with EF Core
This is the real power — test your actual EF Core queries, migrations, and domain logic against SQL Server:
// SqlServerTestFixture.cs — shared setup for all EF Core tests
public class SqlServerTestFixture : IAsyncLifetime
{
private readonly MsSqlContainer _container = new MsSqlBuilder()
.WithPassword("Strong_Passw0rd!")
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
public AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(_container.GetConnectionString())
.Options;
return new AppDbContext(options);
}
public async Task InitializeAsync()
{
await _container.StartAsync();
// Run EF Core migrations — tests run against your actual schema
await using var db = CreateDbContext();
await db.Database.MigrateAsync();
}
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}
// Test class — receives the fixture via xUnit's IClassFixture
public class OrderRepositoryTests : IClassFixture<SqlServerTestFixture>
{
private readonly SqlServerTestFixture _fixture;
public OrderRepositoryTests(SqlServerTestFixture fixture)
=> _fixture = fixture;
[Fact]
public async Task GetByIdAsync_ReturnsOrder_WhenExists()
{
await using var db = _fixture.CreateDbContext();
var repo = new OrderRepository(db);
// Arrange — insert real data
var order = Order.Create(CustomerId.New());
order.AddItem(ProductId.New(), 2, new Money(25m, "GBP"));
await db.Orders.AddAsync(order);
await db.SaveChangesAsync();
// Act — real SQL query against real SQL Server
var result = await repo.GetByIdAsync(order.Id);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(order.Id);
result.Items.Should().HaveCount(1);
result.Items[0].Quantity.Should().Be(2);
}
[Fact]
public async Task GetByCustomerAsync_ReturnsOnlyCustomersOrders()
{
await using var db = _fixture.CreateDbContext();
var repo = new OrderRepository(db);
var customerId1 = CustomerId.New();
var customerId2 = CustomerId.New();
var order1 = Order.Create(customerId1);
var order2 = Order.Create(customerId1);
var order3 = Order.Create(customerId2); // different customer
db.Orders.AddRange(order1, order2, order3);
await db.SaveChangesAsync();
var results = await repo.GetByCustomerAsync(customerId1);
results.Should().HaveCount(2);
results.Should().AllSatisfy(o => o.CustomerId.Should().Be(customerId1));
}
}Test Isolation: Clean Database Between Tests
If tests share a container, leftover data causes flaky tests. Three strategies:
Strategy 1: Respawn (Recommended)
Respawn resets the database to its initial state between tests — far faster than dropping and recreating the database:
dotnet add package Respawnpublic class SqlServerTestFixture : IAsyncLifetime
{
private readonly MsSqlContainer _container = new MsSqlBuilder()
.WithPassword("Strong_Passw0rd!")
.Build();
private Respawner _respawner = default!;
private string _connectionString = string.Empty;
public string ConnectionString => _connectionString;
public async Task InitializeAsync()
{
await _container.StartAsync();
_connectionString = _container.GetConnectionString();
// Run migrations once
await using var db = CreateDbContext();
await db.Database.MigrateAsync();
// Initialise Respawn — it inspects the schema and generates DELETE statements
await using var conn = new SqlConnection(_connectionString);
await conn.OpenAsync();
_respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
TablesToIgnore = ["__EFMigrationsHistory"], // keep migrations table
});
}
// Call this between tests to wipe all data
public async Task ResetDatabaseAsync()
{
await using var conn = new SqlConnection(_connectionString);
await conn.OpenAsync();
await _respawner.ResetAsync(conn);
}
public AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(_connectionString)
.Options;
return new AppDbContext(options);
}
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}// Each test resets the database before running
public class OrderRepositoryTests : IClassFixture<SqlServerTestFixture>, IAsyncLifetime
{
private readonly SqlServerTestFixture _fixture;
public OrderRepositoryTests(SqlServerTestFixture fixture) => _fixture = fixture;
// Reset before each test — clean slate guaranteed
public Task InitializeAsync() => _fixture.ResetDatabaseAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateOrder_PersistsCorrectly()
{
// Arrange
await using var db = _fixture.CreateDbContext();
var order = Order.Create(CustomerId.New());
order.AddItem(ProductId.New(), 3, new Money(10m, "GBP"));
// Act
db.Orders.Add(order);
await db.SaveChangesAsync();
// Assert — query in a fresh DbContext (no caching)
await using var db2 = _fixture.CreateDbContext();
var saved = await db2.Orders.Include(o => o.Items).FirstAsync(o => o.Id == order.Id);
saved.Items.Should().HaveCount(1);
saved.Total.Amount.Should().Be(30m);
}
}Strategy 2: Transaction Rollback
Wrap each test in a transaction that rolls back:
public class TransactionalTestBase : IAsyncLifetime
{
private readonly SqlServerTestFixture _fixture;
private IDbContextTransaction _transaction = null!;
protected AppDbContext Db { get; private set; } = null!;
protected TransactionalTestBase(SqlServerTestFixture fixture)
=> _fixture = fixture;
public async Task InitializeAsync()
{
Db = _fixture.CreateDbContext();
_transaction = await Db.Database.BeginTransactionAsync();
}
public async Task DisposeAsync()
{
// Always rolls back — data never committed
await _transaction.RollbackAsync();
await Db.DisposeAsync();
}
}
public class OrderTests : TransactionalTestBase, IClassFixture<SqlServerTestFixture>
{
public OrderTests(SqlServerTestFixture f) : base(f) {}
[Fact]
public async Task Test_WithAutoRollback()
{
Db.Orders.Add(Order.Create(CustomerId.New()));
await Db.SaveChangesAsync();
// rolled back automatically in DisposeAsync — no cleanup needed
}
}Strategy 3: One Container Per Test Class
Each test class gets its own fresh container. Isolated but slower (more Docker startup time):
// Each test class creates and destroys its own container
public class OrderTests : IAsyncLifetime
{
private readonly MsSqlContainer _container = new MsSqlBuilder().Build();
private AppDbContext _db = null!;
public async Task InitializeAsync()
{
await _container.StartAsync();
_db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(_container.GetConnectionString()).Options);
await _db.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await _db.DisposeAsync();
await _container.DisposeAsync();
}
}Full API Integration Tests with WebApplicationFactory
Test your entire HTTP pipeline — middleware, auth, controllers, EF Core — against a real database:
// CustomWebApplicationFactory.cs
public class CustomWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder()
.WithPassword("Strong_Passw0rd!")
.Build();
private readonly RedisContainer _redisContainer = new RedisBuilder()
.Build();
public async Task InitializeAsync()
{
// Start all containers in parallel
await Task.WhenAll(
_sqlContainer.StartAsync(),
_redisContainer.StartAsync());
}
// Override the app's configuration with container connection strings
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Replace SQL Server connection string
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(_sqlContainer.GetConnectionString()));
// Replace Redis connection string
services.RemoveAll<IConnectionMultiplexer>();
services.AddSingleton<IConnectionMultiplexer>(
ConnectionMultiplexer.Connect(_redisContainer.GetConnectionString()));
});
builder.UseEnvironment("Testing");
}
// Run migrations after containers start but before tests run
public new async Task InitializeAsync()
{
await base.InitializeAsync(); // starts containers
await using var scope = Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
public new async Task DisposeAsync()
{
await base.DisposeAsync();
await Task.WhenAll(
_sqlContainer.DisposeAsync().AsTask(),
_redisContainer.DisposeAsync().AsTask());
}
}// OrdersApiTests.cs
public class OrdersApiTests : IClassFixture<CustomWebApplicationFactory>, IAsyncLifetime
{
private readonly CustomWebApplicationFactory _factory;
private readonly HttpClient _client;
public OrdersApiTests(CustomWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
public Task InitializeAsync() => _factory.ResetDatabaseAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task POST_CreateOrder_Returns201_WithLocation()
{
// Arrange — seed a customer
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var customer = new Customer { Name = "Alice", Email = "alice@example.com" };
db.Customers.Add(customer);
await db.SaveChangesAsync();
var request = new CreateOrderRequest(
CustomerId: customer.Id,
Items: [new(ProductId: 1, Quantity: 2, UnitPrice: 25m)],
Address: "123 Main St");
// Act — real HTTP call through the full pipeline
var response = await _client.PostAsJsonAsync("/api/orders", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
var body = await response.Content.ReadFromJsonAsync<CreateOrderResponse>();
body!.OrderId.Should().BeGreaterThan(0);
// Verify it's actually in the database
var order = await db.Orders.FindAsync(body.OrderId);
order.Should().NotBeNull();
order!.CustomerId.Should().Be(customer.Id);
}
[Fact]
public async Task POST_CreateOrder_Returns400_WhenItemsEmpty()
{
var request = new CreateOrderRequest(
CustomerId: 1,
Items: [], // empty — should fail validation
Address: "123 Main St");
var response = await _client.PostAsJsonAsync("/api/orders", request);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
problem!.Errors.Should().ContainKey("Items");
}
[Fact]
public async Task GET_Order_Returns401_WhenNotAuthenticated()
{
var response = await _client.GetAsync("/api/orders/1");
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
}Testing with Redis
public class CacheServiceTests : IAsyncLifetime
{
private readonly RedisContainer _container = new RedisBuilder().Build();
private IConnectionMultiplexer _redis = null!;
public async Task InitializeAsync()
{
await _container.StartAsync();
_redis = await ConnectionMultiplexer.ConnectAsync(_container.GetConnectionString());
}
public async Task DisposeAsync()
{
await _redis.DisposeAsync();
await _container.DisposeAsync();
}
[Fact]
public async Task SetAsync_ThenGetAsync_ReturnsValue()
{
var cache = new RedisCacheService(_redis);
var key = "order:123";
var order = new OrderDto(123, "ORD-001", 99.99m);
await cache.SetAsync(key, order, TimeSpan.FromMinutes(5));
var cached = await cache.GetAsync<OrderDto>(key);
cached.Should().NotBeNull();
cached!.Id.Should().Be(123);
cached.Reference.Should().Be("ORD-001");
}
[Fact]
public async Task GetAsync_ReturnsNull_AfterExpiry()
{
var cache = new RedisCacheService(_redis);
await cache.SetAsync("short-lived", "value", TimeSpan.FromMilliseconds(100));
await Task.Delay(200); // wait for expiry
var result = await cache.GetAsync<string>("short-lived");
result.Should().BeNull();
}
}Testing Message Bus with RabbitMQ
public class OrderEventTests : IAsyncLifetime
{
private readonly RabbitMqContainer _container = new RabbitMqBuilder()
.WithUsername("guest")
.WithPassword("guest")
.Build();
public Task InitializeAsync() => _container.StartAsync();
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
[Fact]
public async Task PublishOrderConfirmed_ConsumerReceivesEvent()
{
var connectionString = _container.GetConnectionString();
var received = new TaskCompletionSource<OrderConfirmedEvent>();
// Set up consumer first
var factory = new ConnectionFactory { Uri = new Uri(connectionString) };
var connection = await factory.CreateConnectionAsync();
var channel = await connection.CreateChannelAsync();
await channel.QueueDeclareAsync("order-confirmed", durable: false, exclusive: false);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (_, args) =>
{
var body = Encoding.UTF8.GetString(args.Body.ToArray());
var event = JsonSerializer.Deserialize<OrderConfirmedEvent>(body)!;
received.SetResult(event);
await channel.BasicAckAsync(args.DeliveryTag, multiple: false);
};
await channel.BasicConsumeAsync("order-confirmed", autoAck: false, consumer: consumer);
// Publish
var publisher = new OrderEventPublisher(connectionString);
await publisher.PublishOrderConfirmedAsync(new OrderConfirmedEvent(
OrderId: Guid.NewGuid(),
CustomerId: Guid.NewGuid(),
Total: 99.99m));
// Wait for the consumer to receive it (timeout after 5 seconds)
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
result.Total.Should().Be(99.99m);
}
}Shared Container Across All Test Classes (Collection Fixture)
Starting a fresh container for every test class is slow. Use xUnit's Collection Fixture to share one container across multiple test classes:
// SharedContainerFixture.cs — one container for the entire test suite
public class SharedContainerFixture : IAsyncLifetime
{
public MsSqlContainer SqlContainer { get; } = new MsSqlBuilder()
.WithPassword("Strong_Passw0rd!")
.Build();
public string ConnectionString => SqlContainer.GetConnectionString();
private Respawner _respawner = null!;
public async Task InitializeAsync()
{
await SqlContainer.StartAsync();
// Apply migrations once
using var db = CreateDbContext();
await db.Database.MigrateAsync();
// Initialise Respawner
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
_respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
TablesToIgnore = ["__EFMigrationsHistory"],
});
}
public async Task ResetAsync()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
await _respawner.ResetAsync(conn);
}
public AppDbContext CreateDbContext()
=> new(new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(ConnectionString).Options);
public Task DisposeAsync() => SqlContainer.DisposeAsync().AsTask();
}
// Register the collection
[CollectionDefinition("SharedDb")]
public class SharedDbCollection : ICollectionFixture<SharedContainerFixture> { }
// All test classes in the [Collection("SharedDb")] share the same container
[Collection("SharedDb")]
public class OrderRepositoryTests : IAsyncLifetime
{
private readonly SharedContainerFixture _db;
public OrderRepositoryTests(SharedContainerFixture db) => _db = db;
public Task InitializeAsync() => _db.ResetAsync(); // wipe data, keep schema
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task GetById_ReturnsOrder() { /* ... */ }
}
[Collection("SharedDb")]
public class CustomerRepositoryTests : IAsyncLifetime
{
private readonly SharedContainerFixture _db;
public CustomerRepositoryTests(SharedContainerFixture db) => _db = db;
public Task InitializeAsync() => _db.ResetAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task GetAll_ReturnsAllCustomers() { /* ... */ }
}One container starts, both test classes use it, Respawn wipes data between each test. Fast and isolated.
Running in CI (GitHub Actions)
# .github/workflows/tests.yml
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest # Docker is available by default
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.x"
- name: Run integration tests
run: dotnet test --configuration Release --logger "console;verbosity=normal"
env:
# Testcontainers auto-detects Docker — no extra config needed on GitHub Actions
TESTCONTAINERS_RYUK_DISABLED: "false"Testcontainers auto-detects the Docker socket. On GitHub Actions (Ubuntu runner), Docker is available with no additional setup. For Azure DevOps, use a ubuntu-latest agent — it also has Docker pre-installed.
Performance Tips
1. Share containers across test classes (Collection Fixture)
→ One SQL Server container for all 50 tests: 5s startup vs 250s
2. Use Respawn instead of dropping/recreating the database
→ Respawn DELETE: ~50ms vs database drop+migrate: ~3s
3. Run tests in parallel with isolated containers
→ xUnit parallelises test classes by default
→ Each Collection Fixture = one container — safe
4. Layer your tests: unit → integration → E2E
→ Unit tests: ~500ms total (no containers)
→ Integration tests: ~30s total (containers start once)
→ Don't run Testcontainers tests on every file save — only on PR/push
5. Cache Docker images in CI
- GitHub Actions caches Docker layers between runs
- SQL Server 2022 image is ~1.5GB — cached after first pullCommon Mistakes
// ❌ Starting a new container per test method — catastrophically slow
[Fact]
public async Task Test1()
{
await using var container = new MsSqlBuilder().Build();
await container.StartAsync(); // 5s startup × 100 tests = 500s
// ...
}
// ✅ Share via IClassFixture or CollectionFixture — start once, share
public class Tests : IClassFixture<SqlServerTestFixture> { ... }
// ❌ Not cleaning up between tests — data bleeds between tests
[Fact]
public async Task Test_A_InsertsSomeData() { /* inserts 5 rows */ }
[Fact]
public async Task Test_B_CountsRows() { /* expects 0 rows — gets 5 */ }
// ✅ Reset with Respawn in InitializeAsync of each test
public Task InitializeAsync() => _fixture.ResetAsync();
// ❌ Using .Result or .GetAwaiter().GetResult() in async test setup
public void Initialize() { _container.StartAsync().Result; } // can deadlock
// ✅ Use IAsyncLifetime for async setup/teardown
public Task InitializeAsync() => _container.StartAsync();When to Use Testcontainers vs What
✅ Testcontainers: integration tests for repositories, services with real DB
✅ Testcontainers: full API tests via WebApplicationFactory
✅ Testcontainers: testing cache behaviour (Redis), messaging (RabbitMQ/ServiceBus emulator)
✅ Unit tests (no containers): pure domain logic, value objects, business rules
✅ In-memory EF Core: only for tests that don't touch SQL-specific features
❌ Testcontainers: not for unit tests — too slow, not the right tool
❌ Testcontainers: not for tests that only test pure C# logicKey Takeaways
- Mocked databases lie — they pass when your real query would fail; Testcontainers tests what actually runs in production
- One container per collection, not per test — share via
ICollectionFixture, reset data with Respawn - Respawn is the right tool for database cleanup — it's 50× faster than drop/migrate
IAsyncLifetimeis the xUnit hook for async setup and teardown — always use it, never.ResultWebApplicationFactory+ container = a full E2E test of your entire HTTP pipeline against real infrastructure- CI is trivial — GitHub Actions and Azure DevOps both have Docker; Testcontainers just works
WithImage("...")lets you pin the exact Docker image version — same image in dev and CI, no surprises- Testcontainers doesn't replace unit tests — it complements them. Unit test the logic, integration test the persistence
What problem does Testcontainers solve that mocked repository tests cannot?