| name | testcontainers |
| description | .NET Testcontainers patterns for integration tests in the DataNormalizer project. Covers container lifecycle management, database containers, wait strategies, and NUnit 4 integration with OneTimeSetUp/OneTimeTearDown. |
.NET Testcontainers
Overview
Testcontainers is a library for creating lightweight, throwaway Docker containers for integration tests. While DataNormalizer doesn't require database containers for its core functionality, these patterns are useful for integration tests that verify normalization of data retrieved from real data sources.
NuGet Package
Add to Directory.Packages.props:
<ItemGroup Label="Testing">
<PackageVersion Include="Testcontainers" Version="4.3.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.3.0" />
<PackageVersion Include="Testcontainers.MsSql" Version="4.3.0" />
</ItemGroup>
Add to test project .csproj:
<ItemGroup>
<PackageReference Include="Testcontainers.PostgreSql" />
</ItemGroup>
NUnit 4 Integration
Container Lifecycle with OneTimeSetUp/OneTimeTearDown
Use [OneTimeSetUp] and [OneTimeTearDown] to manage container lifecycle at the fixture level. Containers are expensive to start, so share them across tests in a fixture.
[TestFixture]
public sealed class DatabaseIntegrationTests
{
private PostgreSqlContainer _postgres = null!;
private string _connectionString = null!;
[OneTimeSetUp]
public async Task OneTimeSetUp()
{
_postgres = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("testdb")
.WithUsername("test")
.WithPassword("test")
.Build();
await _postgres.StartAsync();
_connectionString = _postgres.GetConnectionString();
}
[OneTimeTearDown]
public async Task OneTimeTearDown()
{
await _postgres.DisposeAsync();
}
[Test]
public async Task Normalize_DataFromPostgres_ProducesExpectedResult()
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();
var data = await ReadPersonsAsync(connection);
var result = TestNormalization.Normalize(data.First());
Assert.That(result.PersonList, Has.Length.GreaterThan(0));
}
}
Container Per Test (When Isolation Required)
For tests that need a clean database:
[TestFixture]
public sealed class IsolatedDatabaseTests
{
[Test]
public async Task Test_WithCleanDatabase()
{
await using var postgres = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
await postgres.StartAsync();
}
}
Database Containers
PostgreSQL
var container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("testdb")
.WithUsername("test")
.WithPassword("test")
.WithPortBinding(5432, true)
.Build();
await container.StartAsync();
var connectionString = container.GetConnectionString();
SQL Server
var container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("Strong_password_123!")
.Build();
await container.StartAsync();
var connectionString = container.GetConnectionString();
Wait Strategies
Testcontainers has built-in wait strategies, but you can customize them:
Default Wait (Port + Health Check)
The builder containers (PostgreSqlBuilder, etc.) include appropriate wait strategies by default. They wait for the database to accept connections.
Custom Wait Strategy
var container = new ContainerBuilder()
.WithImage("custom-service:latest")
.WithPortBinding(8080, true)
.WithWaitStrategy(
Wait.ForUnixContainer()
.UntilHttpRequestIsSucceeded(r => r
.ForPath("/health")
.ForPort(8080)
.ForStatusCode(System.Net.HttpStatusCode.OK)))
.Build();
Wait with Timeout
var container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithStartupCallback((container, ct) =>
{
return Task.CompletedTask;
})
.Build();
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
await container.StartAsync(cts.Token);
Generic Container
For services without a dedicated builder:
var container = new ContainerBuilder()
.WithImage("redis:7-alpine")
.WithPortBinding(6379, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379))
.Build();
await container.StartAsync();
var host = container.Hostname;
var port = container.GetMappedPublicPort(6379);
Shared Container Across Fixtures
For expensive containers shared across multiple test fixtures:
[SetUpFixture]
public sealed class SharedDatabaseFixture
{
public static PostgreSqlContainer Postgres { get; private set; } = null!;
public static string ConnectionString { get; private set; } = "";
[OneTimeSetUp]
public async Task GlobalSetUp()
{
Postgres = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
await Postgres.StartAsync();
ConnectionString = Postgres.GetConnectionString();
}
[OneTimeTearDown]
public async Task GlobalTearDown()
{
await Postgres.DisposeAsync();
}
}
[TestFixture]
public sealed class PersonNormalizationTests
{
private string ConnectionString => SharedDatabaseFixture.ConnectionString;
[Test]
public async Task Test1()
{
}
}
Best Practices
- Use Alpine images when available — smaller download, faster startup
- Use
[OneTimeSetUp]/[OneTimeTearDown] for container lifecycle — don't start/stop per test
- Use random port binding (
WithPortBinding(5432, true)) — avoids port conflicts
- Always
await DisposeAsync() — containers are not cleaned up on GC
- Set reasonable timeouts — CI environments may be slower
- Use
[Category("Integration")] to separate from unit tests:
[TestFixture]
[Category("Integration")]
public sealed class DatabaseTests { }
Run with: dotnet test --filter "Category=Integration"
Docker Requirements
Testcontainers requires Docker to be running. In CI:
services:
jobs:
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
- run: dotnet test --filter "Category=Integration"