Testcontainers for .NET: Real Database Integration Tests
In-memory database providers like SQLite or EF Core's UseInMemoryDatabase are convenient, but they lie to you. They don't enforce foreign keys the same way, don't support the same query syntax, and don't behave like your production database. When you need integration tests you can actually trust, Testcontainers lets you spin up real database instances inside Docker containers that start with your tests and are torn down automatically afterwards.
Why Not In-Memory Providers?
EF Core's in-memory provider doesn't support transactions, raw SQL, or many database-specific features. SQLite in-memory is better but still differs from PostgreSQL or SQL Server in meaningful ways — column collation, JSON queries, and window functions all behave differently.
If your test passes against SQLite but fails against PostgreSQL in production, the test has negative value. It gave you false confidence.
Setting Up Testcontainers
Install the package for your database:
dotnet add package Testcontainers.PostgreSql
// or: Testcontainers.MsSql, Testcontainers.MySql
The library requires Docker to be running on the machine executing the tests. In CI, this is typically available by default.
A Basic PostgreSQL Example
public class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("testdb")
.WithUsername("test")
.WithPassword("test")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async Task InitializeAsync()
{
await _container.StartAsync();
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
}
Use it with xUnit's IClassFixture to share the container across tests:
public class UserRepositoryTests : IClassFixture<PostgresFixture>
{
private readonly AppDbContext _db;
public UserRepositoryTests(PostgresFixture fixture)
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(fixture.ConnectionString)
.Options;
_db = new AppDbContext(options);
_db.Database.EnsureCreated();
}
[Fact]
public async Task CreateUser_PersistsToDatabase()
{
var user = new User { Name = "Alice", Email = "[email protected]" };
_db.Users.Add(user);
await _db.SaveChangesAsync();
var saved = await _db.Users.FirstOrDefaultAsync(u => u.Email == "[email protected]");
Assert.NotNull(saved);
Assert.Equal("Alice", saved.Name);
}
}
Integrating with WebApplicationFactory
The real payoff comes when you combine Testcontainers with WebApplicationFactory for full end-to-end API tests against a real database:
public class ApiTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove the existing DbContext registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_dbContainer.GetConnectionString()));
});
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
// Apply migrations
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await _dbContainer.DisposeAsync();
}
}
Now your API tests hit a real PostgreSQL instance:
public class ProductApiTests : IClassFixture<ApiTestFactory>
{
private readonly HttpClient _client;
public ProductApiTests(ApiTestFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CreateProduct_ReturnsCreatedWithLocation()
{
var product = new { Name = "Widget", Price = 9.99m };
var response = await _client.PostAsJsonAsync("/api/products", product);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
}
}
Using Collection Fixtures for Shared Containers
If multiple test classes need the same database, use xUnit's collection fixtures to avoid starting multiple containers:
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<PostgresFixture> { }
[Collection("Database")]
public class OrderRepositoryTests
{
private readonly PostgresFixture _fixture;
public OrderRepositoryTests(PostgresFixture fixture)
{
_fixture = fixture;
}
// Tests here share the same container
}
CI Considerations
Testcontainers works out of the box on most CI platforms. GitHub Actions, GitLab CI, and Azure DevOps hosted agents all have Docker available. The containers start in seconds — a PostgreSQL container is typically ready in under two seconds.
One thing to watch: container startup adds to your test suite's overall execution time. Share containers across test classes where possible, and use Respawn (covered in a separate article) to reset database state between tests rather than recreating containers.
When to Use Testcontainers
Use Testcontainers when your tests need to verify behaviour that depends on specific database features: constraints, triggers, stored procedures, JSON queries, full-text search, or anything where the in-memory provider would give you a false result. For pure business logic that doesn't touch a database, unit tests remain the better choice.
The small overhead of running Docker containers is well worth the confidence that your data access code actually works against the same database engine you'll use in production.