Your Integration Test Should Bring Its Own Database: Testcontainers for .NET

Your Integration Test Should Bring Its Own Database
An integration test arranges data, starts the application, exercises a boundary, and cleans up afterward.
But many test suites leave their most important dependency outside that lifecycle: the database.
PostgreSQL is started by a workflow, a Compose file, a shell command, or a developer following setup instructions. The test only receives a connection string and hopes the environment is correct.
That is an ownership gap.
If a test depends on PostgreSQL, its fixture should decide which image to run, start it, configure the application, prepare the schema, and dispose it.
Testcontainers for .NET gives the test that control.
The database belongs in Arrange
Treating infrastructure as part of the fixture changes who owns each decision:
| Pipeline-managed database | Test-owned database |
|---|---|
| Image selected in workflow YAML | Image selected in test code |
| Startup handled by the runner | Startup handled by the fixture |
| Connection string assembled manually | Connection string generated at runtime |
| Readiness duplicated as a health check | Readiness handled by the container module |
| Cleanup depends on pipeline behavior | Cleanup follows fixture disposal |
| Local setup documented separately | Local and CI use dotnet test |
The target lifecycle is small:
- Build a PostgreSQL container definition.
- Start the container.
- Give its connection string to the test host.
- Apply the application's EF Core migrations.
- Run tests through the real HTTP and database boundaries.
- Dispose the host and container.
Everything required to reproduce the test is now beside the test.
Add the integration-test project
Create a dedicated xUnit project and reference the ASP.NET Core application:
dotnet new xunit -n Store.Api.IntegrationTests -o tests/Store.Api.IntegrationTests
dotnet add tests/Store.Api.IntegrationTests/Store.Api.IntegrationTests.csproj \
reference src/Store.Api/Store.Api.csproj
dotnet add tests/Store.Api.IntegrationTests/Store.Api.IntegrationTests.csproj \
package Microsoft.AspNetCore.Mvc.Testing
dotnet add tests/Store.Api.IntegrationTests/Store.Api.IntegrationTests.csproj \
package Testcontainers.PostgreSql
Use the same major version of Microsoft.AspNetCore.Mvc.Testing as the application.
Keep application configuration replaceable
The production application does not need a special testing branch. It only needs to read the database connection string through normal .NET configuration:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddDbContext<AppDbContext>(options => options.UseNpgsql( builder.Configuration.GetConnectionString("AppDb") ?? throw new InvalidOperationException( "Connection string 'AppDb' was not found."))); var app = builder.Build(); app.MapControllers(); app.Run(); public partial class Program { }
The partial Program declaration exposes the top-level ASP.NET Core entry point to WebApplicationFactory<Program>.
The application still has one database registration. Production supplies its connection string from production configuration; the fixture supplies a temporary one during tests.
Make the factory own the lifecycle
Add a custom factory to the integration-test project:
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Testcontainers.PostgreSql; public sealed class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime { private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:17.9-alpine") .WithDatabase("app") .WithUsername("postgres") .WithPassword("postgres") .Build(); public async Task InitializeAsync() { await _postgres.StartAsync(); using var scope = Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>(); await dbContext.Database.MigrateAsync(); } protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseSetting( "ConnectionStrings:AppDb", _postgres.GetConnectionString()); } async Task IAsyncLifetime.DisposeAsync() { await base.DisposeAsync(); await _postgres.DisposeAsync(); } }
The fixture pins PostgreSQL to 17.9-alpine, but it does not pin a host port. Testcontainers asks Docker for an available port and returns the complete connection string.
InitializeAsync starts PostgreSQL before the test host is created. Accessing Services builds the host with the temporary connection string, then applies the same migrations used by the application.
The explicit IAsyncLifetime.DisposeAsync implementation lets xUnit clean up both WebApplicationFactory and the container without conflicting with the factory's own async-disposal method.
Avoid postgres:latest. A test should change database versions through a deliberate code change, not because a floating tag moved.
Test the application and PostgreSQL together
Use the factory as an xUnit class fixture:
using System.Net.Http.Json; using System.Text.Json; public sealed class OrdersApiTests : IClassFixture<ApiFactory> { private readonly ApiFactory _factory; public OrdersApiTests(ApiFactory factory) { _factory = factory; } [Fact] public async Task Creating_an_order_persists_it() { using var client = _factory.CreateClient(); var createResponse = await client.PostAsJsonAsync( "/orders", new { Id = "order-123", Total = 42.50m }); createResponse.EnsureSuccessStatusCode(); var order = await client.GetFromJsonAsync<JsonElement>( "/orders/order-123"); Assert.Equal( "order-123", order.GetProperty("id").GetString()); Assert.Equal( 42.50m, order.GetProperty("total").GetDecimal()); } }
Replace the route and payload with your application's contract.
This test crosses several boundaries in one request:
- ASP.NET Core routing and model binding;
- dependency-injection registrations;
- EF Core's PostgreSQL provider;
- the applied database migrations;
- PostgreSQL constraints and transaction behavior;
- response serialization.
An in-memory substitute remains useful for fast tests in some codebases, but it cannot validate PostgreSQL SQL translation, provider-specific behavior, or the deployed schema.
Use the real engine where those differences are the risk.
Let CI run tests, not design the fixture
GitHub-hosted Ubuntu runners include Docker, which is the only runtime dependency Testcontainers needs.
The workflow can remain a normal .NET test job:
name: tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- run: dotnet restore
- run: dotnet test --no-restore
The runner does not need a PostgreSQL service definition or an application connection string. It executes the same test command as a workstation with Docker.
Self-hosted runners must provide a compatible Docker installation and grant the runner account access to it.
Choose the fixture scope deliberately
The example creates one container for an xUnit class fixture, not one container for every test method.
That is a practical default:
- startup cost is paid once for the class;
- each test can use unique identifiers;
- the whole fixture still receives a fresh database instance;
- disposal remains automatic.
If tests mutate shared rows, reset the schema between tests or move independent groups into separate fixtures. Start with the class boundary and add more isolation only when shared state causes a real failure.
Keep pure calculations and business rules in unit tests. Use a container when confidence depends on the actual database, migrations, transactions, or application wiring.
A self-contained integration test is easier to trust
A reliable test should carry the instructions for creating its environment.
With Testcontainers, the PostgreSQL version, startup, connection, migration, and cleanup rules live in the fixture that needs them. The pipeline only provides Docker and runs the test command.
That design gives developers and CI one executable definition of the integration environment.
The test brings its own database. Every runner follows the same lifecycle.
Further reading
- Testcontainers for .NET: PostgreSQL module
- Testcontainers for .NET: Continuous Integration
- Integration tests in ASP.NET Core
Takeaway: Make the fixture responsible for PostgreSQL so the test owns every dependency required to reproduce its result.
