Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 054 / 100

Testcontainers for real databases in .NET & ASP.NET Core

Stop mocking your database. Run the real one in Docker.

Expona Admin8 min read

Testcontainers for real databases

You've written tests that pass locally but fail in production. The culprit? Your in-memory database mock didn't respect constraints, transactions, or the actual SQL behavior your code relies on. Testcontainers for real databases solves this by spinning up a genuine PostgreSQL, MySQL, or SQL Server instance in Docker for each test run, then tearing it down. No more guessing whether your data access layer actually works.

Why not in-memory providers

EF Core's in-memory database provider is convenient. It's fast, it requires no setup, and tests run instantly. But that speed comes at a cost: it ignores most of what makes relational databases relational.

Consider this scenario. You have a User entity with a unique constraint on email, and a foreign key relationship to an Organization. Your code relies on the database to enforce these rules.

csharp

public class User
{
    public int Id { get; set; }
    public string Email { get; set; }
    public int OrganizationId { get; set; }
    public Organization Organization { get; set; }
}

public class Organization
{
    public int Id { get; set; }
    public string Name { get; set; }
    public List<User> Users { get; set; }
}

With the in-memory provider, this test passes:

csharp

[Fact]
public async Task InsertDuplicateEmail_ShouldThrow()
{
    var options = new DbContextOptionsBuilder<AppDbContext>()
        .UseInMemoryDatabase("test-db")
        .Options;
    
    using var context = new AppDbContext(options);
    var org = new Organization { Id = 1, Name = "Acme" };
    context.Organizations.Add(org);
    await context.SaveChangesAsync();
    
    var user1 = new User { Email = "alice@example.com", OrganizationId = 1 };
    var user2 = new User { Email = "alice@example.com", OrganizationId = 1 };
    
    context.Users.Add(user1);
    context.Users.Add(user2);
    
    // This doesn't throw. In-memory provider ignores unique constraints.
    await context.SaveChangesAsync();
}

But in production, inserting a duplicate email fails immediately. Your test gave you false confidence.

The in-memory provider also ignores:

  • Unique and composite key constraints
  • Check constraints
  • Cascade delete behavior (partially)
  • Transaction isolation levels
  • SQL-specific behavior like string collation or null handling in aggregates
  • Index performance characteristics

Your tests pass, but your code fails under real database conditions. That's worse than a failing test—it's a false positive.

Spin up Postgres per test run

Testcontainers is a library that manages Docker containers from your test code. For .NET, you use the Testcontainers library (available on NuGet). When your test starts, it pulls and runs a real database image. When it ends, the container is cleaned up.

Install the package:

csharp

dotnet add package Testcontainers.PostgreSql

Now write your test fixture:

csharp

using Testcontainers.PostgreSql;
using Xunit;

public class DatabaseFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container;
    public string ConnectionString { get; private set; }

    public DatabaseFixture()
    {
        _container = new PostgreSqlBuilder()
            .WithImage("postgres:16")
            .WithDatabase("testdb")
            .WithUsername("testuser")
            .WithPassword("testpass")
            .Build();
    }

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        ConnectionString = _container.GetConnectionString();
    }

    public async Task DisposeAsync()
    {
        await _container.StopAsync();
    }
}

public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _fixture;

    public UserRepositoryTests(DatabaseFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task InsertDuplicateEmail_ShouldThrow()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(_fixture.ConnectionString)
            .Options;

        using var context = new AppDbContext(options);
        await context.Database.MigrateAsync();

        var org = new Organization { Id = 1, Name = "Acme" };
        context.Organizations.Add(org);
        await context.SaveChangesAsync();

        var user1 = new User { Email = "alice@example.com", OrganizationId = 1 };
        var user2 = new User { Email = "alice@example.com", OrganizationId = 1 };

        context.Users.Add(user1);
        await context.SaveChangesAsync();

        context.Users.Add(user2);
        
        // This now throws DbUpdateException because the unique constraint is real
        await Assert.ThrowsAsync<DbUpdateException>(
            () => context.SaveChangesAsync()
        );
    }
}

The first test run pulls the Postgres image (a one-time download) and starts a container. Subsequent runs reuse the image layer, so startup is fast. When the test completes, the container is removed.

Each test gets a fresh database instance. You're no longer chasing weird state from previous tests.

Reset data fast

Running a full migration before each test is slow. You can start a container once per test class, but then you need to clean up data between tests. That's where Respawn comes in.

Respawn is a library that deletes all data from your database without dropping the schema. It's fast because it's just a series of DELETE statements in the right order (respecting foreign keys).

Install Respawn:

csharp

dotnet add package Respawn

Update your fixture to run migrations once and reset data between tests:

csharp

using Respawn;
using Testcontainers.PostgreSql;
using Xunit;

public class DatabaseFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container;
    private Respawner _respawner;
    public string ConnectionString { get; private set; }

    public DatabaseFixture()
    {
        _container = new PostgreSqlBuilder()
            .WithImage("postgres:16")
            .WithDatabase("testdb")
            .WithUsername("testuser")
            .WithPassword("testpass")
            .Build();
    }

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        ConnectionString = _container.GetConnectionString();

        // Run migrations once
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(ConnectionString)
            .Options;

        using var context = new AppDbContext(options);
        await context.Database.MigrateAsync();

        // Initialize Respawn to handle data cleanup
        using var conn = new NpgsqlConnection(ConnectionString);
        await conn.OpenAsync();
        _respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
        {
            DbAdapter = DbAdapter.Postgres
        });
    }

    public async Task ResetAsync()
    {
        using var conn = new NpgsqlConnection(ConnectionString);
        await conn.OpenAsync();
        await _respawner.ResetAsync(conn);
    }

    public async Task DisposeAsync()
    {
        await _container.StopAsync();
    }
}

public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _fixture;

    public UserRepositoryTests(DatabaseFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task InsertDuplicateEmail_ShouldThrow()
    {
        await _fixture.ResetAsync();

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(_fixture.ConnectionString)
            .Options;

        using var context = new AppDbContext(options);
        // ... test code
    }

    [Fact]
    public async Task GetUserById_ReturnsUser()
    {
        await _fixture.ResetAsync();

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(_fixture.ConnectionString)
            .Options;

        using var context = new AppDbContext(options);
        // ... test code
    }
}

Respawn respects foreign key constraints and deletes rows in the correct order. A full reset takes milliseconds, not seconds. This scales to dozens or hundreds of tests in your suite.

Runs in CI

GitHub Actions runners have Docker built in. The same Testcontainers setup that works on your machine works in your CI/CD pipeline without extra configuration.

Your GitHub Actions workflow doesn't need to spin up a separate Postgres service:

csharp

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-dotnet@v3
        with:
          dotnet-version: '8.0.x'
      - run: dotnet restore
      - run: dotnet test --no-restore --logger "trx;LogFileName=results.trx"
      - uses: dorny/test-reporter@v1
        if: always()
        with:
          name: Test Results
          path: '**/results.trx'
          reporter: 'dotnet trx'

When your tests run, Testcontainers automatically starts a Postgres container. The runner already has Docker; no manual service setup needed. Your integration tests run the same way locally and in CI.

This consistency is invaluable. You catch environment-specific bugs before code reaches production.

Common mistakes

Not waiting for container startup: Testcontainers handles this in StartAsync(), but if you try to connect before the container is fully ready, your tests will fail sporadically. Always await StartAsync() before using the connection string.

Sharing a container across test classes: If multiple test classes share one fixture and one container, tests become interdependent. If one test leaves bad state, others fail unpredictably. Use either one container per test class (via IClassFixture) or one per test (via IAsyncLifetime and fresh instantiation). The cost of spinning up a container is usually worth the isolation.

Forgetting to reset data between tests: If you don't use Respawn or equivalent cleanup, inserting the same data twice will fail on unique constraints. Your tests will pass in isolation but fail when run together. Always reset.

Running migrations on every test: Migrations are slow. Run them once during fixture initialization, not before each test. Use Respawn to clean data, not migrations.

Mixing test databases: Don't use a real development or staging database. Testcontainers creates an isolated instance per test run. This isolation is the whole point—your tests never touch shared infrastructure.

Key takeaways

  • In-memory databases ignore constraints, transactions, and SQL behavior. Tests pass but production fails. Real databases catch these bugs early.
  • Testcontainers starts a Docker container for your tests and tears it down automatically. One container per test class is a good default.
  • Run migrations once during fixture setup, not before each test. Use Respawn to reset data between tests in milliseconds.
  • Testcontainers work in CI because GitHub Actions runners have Docker. Your integration tests run identically locally and in the pipeline.
  • Isolate each test's data. Use separate containers or aggressive cleanup to avoid interdependent tests that fail mysteriously when run together.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles