Integration Testing in .NET with Testcontainers

Integration Testing in .NET with Testcontainers

Integration tests that mock the database lie. The query that passes with an in-memory mock fails against real PostgreSQL. The transaction that works in isolation deadlocks under the ORM's actual behavior. Testcontainers for .NET solves this by spinning up real Docker containers — real databases, real Redis, real message brokers — in your test suite, then tearing them down when done.

What Testcontainers Does

Testcontainers starts Docker containers programmatically from your test code. Each test run gets a fresh, isolated container. No shared database state between test runs, no "works on my machine" issues, no separate test database server to maintain.

dotnet add package Testcontainers
dotnet add package Testcontainers.PostgreSql   # database-specific modules
dotnet add package Testcontainers.Redis
dotnet add package Testcontainers.MsSql

You need Docker running locally (or in CI). That's the only prerequisite.

PostgreSQL Integration Test

Here's a complete example testing a repository against real PostgreSQL:

using Testcontainers.PostgreSql;
using Npgsql;
using Dapper;

public class UserRepositoryTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:15-alpine")
        .WithDatabase("testdb")
        .WithUsername("testuser")
        .WithPassword("testpass")
        .Build();

    private IDbConnection _connection;
    private UserRepository _repository;

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();
        
        _connection = new NpgsqlConnection(_postgres.GetConnectionString());
        await _connection.OpenAsync();
        
        // Run migrations or schema creation
        await _connection.ExecuteAsync(@"
            CREATE TABLE users (
                id SERIAL PRIMARY KEY,
                name VARCHAR(100) NOT NULL,
                email VARCHAR(200) UNIQUE NOT NULL,
                created_at TIMESTAMPTZ DEFAULT NOW()
            )");

        _repository = new UserRepository(_connection);
    }

    public async Task DisposeAsync()
    {
        await _postgres.DisposeAsync();
    }

    [Fact]
    public async Task CreateUser_PersistsToDatabase()
    {
        var user = new User { Name = "Alice", Email = "alice@example.com" };
        
        var created = await _repository.CreateAsync(user);
        
        created.Id.Should().BePositive();
        created.Name.Should().Be("Alice");
        
        // Verify it's actually in the database
        var fromDb = await _repository.FindByEmailAsync("alice@example.com");
        fromDb.Should().NotBeNull();
        fromDb!.Name.Should().Be("Alice");
    }

    [Fact]
    public async Task CreateUser_DuplicateEmail_ThrowsException()
    {
        var user = new User { Name = "Alice", Email = "alice@example.com" };
        await _repository.CreateAsync(user);

        Func<Task> duplicate = () => _repository.CreateAsync(
            new User { Name = "Alice Clone", Email = "alice@example.com" });

        await duplicate.Should().ThrowAsync<PostgresException>()
            .Where(ex => ex.SqlState == "23505"); // unique violation
    }
}

The test uses the actual PostgreSQL UNIQUE constraint enforcement — something an in-memory mock can't replicate.

NUnit Setup (ClassFixture equivalent)

NUnit uses [OneTimeSetUp] and [OneTimeTearDown] for per-class container lifecycle:

[TestFixture]
public class OrderRepositoryTests
{
    private PostgreSqlContainer _postgres;
    private IDbConnection _connection;

    [OneTimeSetUp]
    public async Task SetUp()
    {
        _postgres = new PostgreSqlBuilder()
            .WithImage("postgres:15-alpine")
            .Build();
        
        await _postgres.StartAsync();
        _connection = new NpgsqlConnection(_postgres.GetConnectionString());
        await _connection.OpenAsync();
        await RunMigrationsAsync(_connection);
    }

    [OneTimeTearDown]
    public async Task TearDown()
    {
        await _postgres.DisposeAsync();
    }

    [SetUp]
    public async Task BeforeEach()
    {
        // Clear data between tests
        await _connection.ExecuteAsync("DELETE FROM orders");
    }
}

Testing with SQL Server

using Testcontainers.MsSql;

var mssql = new MsSqlBuilder()
    .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
    .WithPassword("StrongPass123!")
    .Build();

await mssql.StartAsync();

var connectionString = mssql.GetConnectionString();
// Use with EF Core, Dapper, or SqlConnection directly

SQL Server-specific features — JSON columns, computed columns, specific collations — work exactly as they do in production.

Redis Integration Tests

using Testcontainers.Redis;
using StackExchange.Redis;

public class CacheServiceTests : IAsyncLifetime
{
    private readonly RedisContainer _redis = new RedisBuilder()
        .WithImage("redis:7-alpine")
        .Build();

    private IConnectionMultiplexer _connection;
    private CacheService _cache;

    public async Task InitializeAsync()
    {
        await _redis.StartAsync();
        _connection = await ConnectionMultiplexer.ConnectAsync(_redis.GetConnectionString());
        _cache = new CacheService(_connection);
    }

    public async Task DisposeAsync() => await _redis.DisposeAsync();

    [Fact]
    public async Task Set_ThenGet_ReturnsValue()
    {
        await _cache.SetAsync("user:1", new User { Name = "Alice" }, TimeSpan.FromHours(1));
        
        var result = await _cache.GetAsync<User>("user:1");
        
        result.Should().NotBeNull();
        result!.Name.Should().Be("Alice");
    }

    [Fact]
    public async Task Get_AfterExpiry_ReturnsNull()
    {
        await _cache.SetAsync("key", "value", TimeSpan.FromMilliseconds(50));
        
        await Task.Delay(100); // wait for expiry
        
        var result = await _cache.GetAsync<string>("key");
        result.Should().BeNull();
    }
}

Testing TTL expiry against a real Redis instance catches bugs that mock-based tests never will.

ASP.NET Core Integration Testing

Combine Testcontainers with WebApplicationFactory for full-stack integration tests:

public class ApiIntegrationTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;

    public ApiIntegrationTests(CustomWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetUsers_ReturnsSeededUsers()
    {
        var response = await _client.GetAsync("/api/users");
        var users = await response.Content.ReadFromJsonAsync<List<UserDto>>();

        response.StatusCode.Should().Be(HttpStatusCode.OK);
        users.Should().HaveCountGreaterThan(0);
    }
}

public class CustomWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:15-alpine")
        .Build();

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();
    }

    public new async Task DisposeAsync()
    {
        await _postgres.DisposeAsync();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Replace the real database connection with the test container's
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            
            if (descriptor != null)
                services.Remove(descriptor);

            services.AddDbContext<AppDbContext>(options =>
                options.UseNpgsql(_postgres.GetConnectionString()));
        });

        builder.ConfigureAppConfiguration((context, config) =>
        {
            config.AddInMemoryCollection(new Dictionary<string, string>
            {
                ["ConnectionStrings:Default"] = _postgres.GetConnectionString()
            });
        });
    }
}

This replaces the database connection string at test startup. Your API code, EF Core migrations, and all middleware run exactly as they do in production — just against the test container.

Running EF Core Migrations

public async Task InitializeAsync()
{
    await _postgres.StartAsync();
    
    var options = new DbContextOptionsBuilder<AppDbContext>()
        .UseNpgsql(_postgres.GetConnectionString())
        .Options;

    using var context = new AppDbContext(options);
    await context.Database.MigrateAsync(); // runs your actual migrations
    
    // Seed test data
    context.Users.AddRange(
        new User { Name = "Alice", Email = "alice@test.com" },
        new User { Name = "Bob", Email = "bob@test.com" }
    );
    await context.SaveChangesAsync();
}

Your migrations run against the real database engine. Schema mismatches are caught in tests, not production.

Performance: Shared Containers

Starting a container takes 1-5 seconds. For a large test suite, per-test containers add up. Share containers across tests in the same class:

// xUnit: use IClassFixture
public class MyTests : IClassFixture<DatabaseFixture>
{
    public MyTests(DatabaseFixture fixture)
    {
        // fixture is shared across all tests in this class
    }
}

public class DatabaseFixture : IAsyncLifetime
{
    public PostgreSqlContainer Postgres { get; } = new PostgreSqlBuilder().Build();
    public string ConnectionString => Postgres.GetConnectionString();

    public async Task InitializeAsync() => await Postgres.StartAsync();
    public async Task DisposeAsync() => await Postgres.DisposeAsync();
}

Each test gets a clean state via transaction rollback or table truncation, without restarting the container:

[Collection("Database")]
public class UserTests : IAsyncLifetime
{
    private readonly DatabaseFixture _fixture;
    private IDbTransaction _transaction;

    public UserTests(DatabaseFixture fixture) => _fixture = fixture;

    public async Task InitializeAsync()
    {
        _transaction = await _fixture.Connection.BeginTransactionAsync();
    }

    public async Task DisposeAsync()
    {
        await _transaction.RollbackAsync(); // undo all changes from this test
    }
}

Each test runs in a transaction and rolls back. Database state is isolated between tests with zero overhead from container restarts.

CI Integration

Testcontainers works in CI as long as Docker is available. For GitHub Actions:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v3
        with:
          dotnet-version: '8.0'
      - name: Run tests
        run: dotnet test --logger "github;annotations=true"

GitHub Actions runners have Docker preinstalled. Testcontainers pulls the image, starts the container, runs your tests, and cleans up automatically.

For faster CI, cache Docker images:

- name: Cache Docker images
  uses: ScribeMD/docker-cache@0.3.7
  with:
    key: docker-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}

When to Use Testcontainers

Use Testcontainers for:

  • Repository layer tests (database queries, transactions, constraints)
  • Cache layer tests (expiry, serialization, eviction)
  • Message queue tests (RabbitMQ, Kafka, Azure Service Bus emulators)
  • Full-stack API tests against realistic infrastructure

Don't use it for:

  • Pure unit tests (business logic with no I/O)
  • Tests that run on every save (use mocks there; Testcontainers is a CI concern)

The rule: if the bug you're worried about is in the database interaction — the query, the schema, the constraints — only a real database will catch it. Testcontainers makes that practical.


Integration tests with Testcontainers cover your data layer. For end-to-end browser testing and 24/7 uptime monitoring of your .NET applications, HelpMeTest covers the full stack without infrastructure overhead.

Read more

Start now free