Real Databases with Testcontainers
Run integration tests against a real PostgreSQL in Docker with Testcontainers, apply migrations once and reset data between tests.
var pg = new PostgreSqlBuilder("postgres:17-alpine").Build();
await pg.StartAsync();
factory.WithWebHostBuilder(b =>
b.UseSetting("ConnectionStrings:Orders", pg.GetConnectionString()));
await db.Database.MigrateAsync();The bugs that reach production from a data layer are engine bugs: a unique index that turns a double submit into a 500, a citext or collation rule, a jsonb query, an xmin concurrency check, a migration that fails on existing rows. The EF Core InMemory provider has no SQL, no constraints and no transactions, and SQLite is a different engine. Neither can see those bugs. Testcontainers starts the same PostgreSQL image you run in production, in Docker, for the length of the test run.
How it works
Testcontainers.PostgreSql (4.16.0) talks to the Docker API. new PostgreSqlBuilder("postgres:17-alpine").Build() describes a container; StartAsync() pulls the image if needed, starts it with the container port mapped to a random free host port, and waits until PostgreSQL accepts connections. GetConnectionString() returns a connection string with that host and port. DisposeAsync() stops and removes the container, and a small reaper container (Ryuk) removes it anyway if the test process is killed. Random ports mean parallel CI jobs on one machine never collide.
Lifetime: once per run, not per test
Starting PostgreSQL takes seconds, plus the image pull on a cold machine. Start one container in a fixture shared by every test that needs it (ICollectionFixture or an assembly fixture) and create the WebApplicationFactory in the same fixture, pointing ConnectionStrings:Orders at the container with UseSetting. Apply the schema with Database.MigrateAsync(), not EnsureCreatedAsync(): EnsureCreated builds tables straight from the model, so a forgotten migration or a broken one passes every test and fails the production deploy.
Isolation between tests
| Strategy | How | Trade-off |
|---|---|---|
| Unique data per test | Each test creates its own customer and orders with fresh IDs | Fastest, allows parallel tests; cannot assert totals across the table |
| Truncate between tests | TRUNCATE ... RESTART IDENTITY CASCADE on every table but the migrations history (the Respawn library automates this) | Simple and exact; tests sharing the database must run serially |
| Transaction per test, rolled back | Open a transaction in the test, roll back at the end | Breaks through HTTP: the app uses its own connections and commits |
| Database per test class | CREATE DATABASE ... TEMPLATE from a migrated template | Full isolation and parallelism, more setup code |
Most suites mix the first two: unique data by default, and a reset in the few test classes that count rows or rely on empty tables. Tests that reset must sit in one xUnit collection, because collections run in parallel and a truncate in one class wipes the rows another class is asserting on.
Running in CI
Linux hosted runners on GitHub Actions and most GitLab runners have Docker; that is all Testcontainers needs. Pin the image tag to the major version production runs (postgres:17-alpine, not latest), so a new PostgreSQL release cannot change test results overnight. When Docker is missing, the fixture fails fast with a clear error; do not silently skip, or the pipeline goes green without ever touching a database.
Example
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;
// One PostgreSQL container and one test host for every class in the "Postgres" collection.
public sealed class PostgresApiFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:17-alpine")
.WithDatabase("orders")
.WithUsername("orders_app")
.WithPassword("test-only-password")
.Build();
public WebApplicationFactory<Program> Factory { get; private set; } = null!;
public async ValueTask InitializeAsync()
{
await _postgres.StartAsync(TestContext.Current.CancellationToken);
Factory = new WebApplicationFactory<Program>().WithWebHostBuilder(web =>
{
web.UseEnvironment("Testing");
web.UseSetting("ConnectionStrings:Orders", _postgres.GetConnectionString());
});
using var scope = Factory.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<OrdersDb>().Database.MigrateAsync(TestContext.Current.CancellationToken);
}
public async Task ResetAsync()
{
await using var connection = new NpgsqlConnection(_postgres.GetConnectionString());
await connection.OpenAsync();
await using var truncate = new NpgsqlCommand("""
DO $$
DECLARE tables text;
BEGIN
SELECT string_agg(format('%I.%I', schemaname, tablename), ', ') INTO tables
FROM pg_tables
WHERE schemaname = 'public' AND tablename <> '__EFMigrationsHistory';
IF tables IS NOT NULL THEN
EXECUTE 'TRUNCATE ' || tables || ' RESTART IDENTITY CASCADE';
END IF;
END $$;
""", connection);
await truncate.ExecuteNonQueryAsync();
}
public async ValueTask DisposeAsync()
{
await Factory.DisposeAsync();
await _postgres.DisposeAsync();
}
}
[CollectionDefinition("Postgres")]
public sealed class PostgresCollection : ICollectionFixture<PostgresApiFixture>;
[Collection("Postgres")]
public sealed class OrderPersistenceTests(PostgresApiFixture fixture) : IAsyncLifetime
{
public async ValueTask InitializeAsync() => await fixture.ResetAsync();
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact]
public async Task Duplicate_order_number_returns_409_not_500()
{
using var client = fixture.Factory.CreateClient();
var body = new { orderNumber = "SO-2026-00042", customerId = "CUST-17", total = 129.90m };
using var first = await client.PostAsJsonAsync("/orders", body, TestContext.Current.CancellationToken);
using var second = await client.PostAsJsonAsync("/orders", body, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); // the unique index, enforced by PostgreSQL
}
[Fact]
public async Task Customer_search_ignores_case_like_production()
{
using var client = fixture.Factory.CreateClient();
await client.PostAsJsonAsync("/orders",
new { orderNumber = "SO-2026-00043", customerId = "CUST-17", total = 34.50m }, TestContext.Current.CancellationToken);
var found = await client.GetFromJsonAsync<OrderSummary[]>("/orders?customerId=cust-17", TestContext.Current.CancellationToken);
Assert.Single(found!);
}
private sealed record OrderSummary(string OrderNumber, decimal Total);
}
// Stand-ins for the API project's types.
public sealed class OrdersDb(DbContextOptions<OrdersDb> options) : DbContext(options);
// Stand-in so this file compiles on its own. Delete it in a real test project: in .NET 10 the API's
// generated Program is already public, and a Program declared here would shadow it (CS0436).
public partial class Program;When to use it
- Prove that submitting order SO-2026-00042 twice returns 409 because PostgreSQL's unique index rejects it, instead of a 500 from an unhandled exception.
- Run every EF Core migration against a fresh PostgreSQL 17 on each pull request so a migration that fails on a real engine never reaches the deploy.
- Test the xmin-based optimistic concurrency on stock levels, which neither SQLite nor the InMemory provider can reproduce.
More examples
A concurrency test only a real engine can run
Two contexts read the same row, the first saves, the second saves a stale copy. With Npgsql a <code>uint</code> property marked <code>IsRowVersion()</code> maps to the <code>xmin</code> system column, so the second <code>UPDATE ... WHERE xmin = @old</code> touches zero rows and EF Core throws <code>DbUpdateConcurrencyException</code>. Without the token both saves succeed and 6 kettles are sold from a stock of 5. SQLite has no <code>xmin</code> and the InMemory provider runs no SQL, so this test is meaningful only against PostgreSQL. The container lives in an assembly fixture, so adding tests or test classes does not add container starts; the unique SKU per test is what lets them share one database.
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;
using Xunit;
// One container for the whole run, shared by every class that asks for StockDbFixture.
[assembly: AssemblyFixture(typeof(StockDbFixture))]
public sealed class StockDbFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:17-alpine").Build();
public async ValueTask InitializeAsync()
{
await _postgres.StartAsync(TestContext.Current.CancellationToken);
await using var db = NewContext();
await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); // schema-only test, no migrations needed
}
public ValueTask DisposeAsync() => _postgres.DisposeAsync();
public InventoryDb NewContext() =>
new(new DbContextOptionsBuilder<InventoryDb>().UseNpgsql(_postgres.GetConnectionString()).Options);
}
public sealed class StockConcurrencyTests(StockDbFixture db)
{
[Fact]
public async Task Second_writer_with_a_stale_row_gets_a_concurrency_exception()
{
var ct = TestContext.Current.CancellationToken;
var sku = $"KETTLE-{Guid.NewGuid():N}"; // each test seeds its own row, so tests can share the database
await using (var seed = db.NewContext())
{
seed.Stock.Add(new StockLevel { Sku = sku, Available = 5 });
await seed.SaveChangesAsync(ct);
}
await using var first = db.NewContext();
await using var second = db.NewContext();
var a = await first.Stock.SingleAsync(s => s.Sku == sku, ct);
var b = await second.Stock.SingleAsync(s => s.Sku == sku, ct);
a.Available -= 2;
await first.SaveChangesAsync(ct);
b.Available -= 4;
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => second.SaveChangesAsync(ct));
}
}
public sealed class InventoryDb(DbContextOptions<InventoryDb> options) : DbContext(options)
{
public DbSet<StockLevel> Stock => Set<StockLevel>();
protected override void OnModelCreating(ModelBuilder model) =>
model.Entity<StockLevel>().Property(s => s.Version).IsRowVersion(); // maps to PostgreSQL's xmin
}
public sealed class StockLevel
{
public int Id { get; set; }
public required string Sku { get; set; }
public int Available { get; set; }
public uint Version { get; set; }
}A fresh database per test class from a migrated template
Copying a template database is a file-level copy inside PostgreSQL, much faster than running every migration again, so each test class can own a database and run in parallel with the others. The <code>ClearPool</code> call is the part people miss: Npgsql keeps idle connections to the template open in its pool, and <code>CREATE DATABASE ... TEMPLATE</code> then fails with "source database is being accessed by other users". The name is generated, never user input, which is why string interpolation into the DDL is safe here.
using Npgsql;
public sealed class TemplateDatabases(string adminConnectionString)
{
private const string Template = "orders_template";
// Called once, after MigrateAsync has run against orders_template.
public async Task<string> CreateFromTemplateAsync(CancellationToken ct)
{
var name = "orders_" + Guid.NewGuid().ToString("N")[..12];
// CREATE DATABASE ... TEMPLATE fails while anything is connected to the template,
// and pooled idle connections count.
await using (var templateConnection = new NpgsqlConnection(WithDatabase(Template)))
{
NpgsqlConnection.ClearPool(templateConnection);
}
await using var admin = new NpgsqlConnection(adminConnectionString);
await admin.OpenAsync(ct);
await using var create = new NpgsqlCommand($"CREATE DATABASE \"{name}\" TEMPLATE \"{Template}\"", admin);
await create.ExecuteNonQueryAsync(ct);
return WithDatabase(name);
}
private string WithDatabase(string database) =>
new NpgsqlConnectionStringBuilder(adminConnectionString) { Database = database }.ConnectionString;
}Running the container tests on GitHub Actions
Linux runners ship with Docker, so the job needs no service containers or database setup: Testcontainers starts and removes PostgreSQL itself. Pre-pulling the image keeps the first test's fixture from spending its timeout on a download. <code>timeout-minutes</code> caps a hung run at 20 minutes instead of the default six hours. The SDK version comes from <code>global.json</code>, so CI and laptops build with the same compiler.
name: integration-tests
on:
pull_request:
push:
branches: [main]
jobs:
integration:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v5
with:
global-json-file: global.json
- name: Pre-pull the database image
run: docker pull postgres:17-alpine
- name: Integration tests
run: dotnet test --project tests/Shop.Api.IntegrationTests --configuration Release
Discussion