Testcontainers in .NET: The Integration Testing Stack That Works Everywhere

June 04, 2026
Categories: #testing
Testcontainers in .NET: The Integration Testing Stack That Works Everywhere
Kevin Lloyd

Kevin Lloyd, Senior Consultant

Integration testing wasn’t a philosophy I adopted. It was forced on me.

My first encounter with automated testing in the real world was on a legacy ASP.NET application with zero tests. DbContext was used everywhere — in controllers, in services, in places that would make your skin crawl. Business logic and data access thoroughly intertwined. The kind of codebase where you pull on one thread and half the application follows. Also, we were 85% through with our Stored Procedure to EF Core migration.

Unit testing that code was a non-starter. You can’t unit test code deeply coupled to infrastructure without first doing the refactoring you need the tests to cover. Michael Feathers called this the Legacy Code Dilemma in Working Effectively with Legacy Code.

When we change code, we should have tests in place. To put tests in place, we often have to change code.

Classic chicken and egg.

So I wrote integration tests instead. And honestly? I never looked back.

Not only did they give me coverage where unit tests couldn’t reach, they became the safety net for everything that followed. I’d cover a chunk of messy code with integration tests, refactor it into something cleaner, and the tests would tell me immediately if when I’d broken the behavior. Structural changes didn’t break them — because they tested what the code did, not how it was organized internally.

The Test Diamond 🔷

The conventional wisdom is the test pyramid — lots of unit tests at the base, some integration tests in the middle, a few end-to-end tests at the top. Reasonable starting point for clean greenfield code.

I prefer a different shape: the test diamond.

Narrow at the bottom (a small number of unit tests for pure, isolated logic), wide in the middle (integration tests as the primary workhorse), and narrow again at the top (minimal end-to-end tests). The wide part — the integration layer — is where most of your coverage lives and where you get the most value per test written.

Unit tests still have their place. Pure domain calculations, anything that runs in complete isolation without infrastructure — unit tests are great there. But I’ve found they can have a frustrating habit of breaking during refactoring — not because behavior changed, but because internal structure did. Extract a method, introduce a service, and suddenly you have a dozen tests to update alongside the code. Mocking compounds the problem — the more you mock, the more your tests are coupled to implementation details rather than behavior. Changing tests while changing code is double the work, and half of it feels pointless.

The only dependencies I reach for mocks on are the ones I genuinely don’t control — Stripe, an external shipping API, an SMS gateway. Your database isn’t in that category. For the application behavior that actually matters — does the right data get saved, does the right HTTP response come back, do the queries actually work — integration tests are the better investment.

And you get more than just endpoint coverage. Your migrations run as part of the test setup, so a botched migration fails your tests before it fails production. Your EF Core queries run against a real database engine, so a missing .Include() that would silently return null in production will blow up in your test suite where you can actually fix it. You’re testing the full thing — schema, queries, serialization, the pipeline — all in one shot.

If you want the full argument for why we use real database integration tests (and why the in-memory EF Core provider is a trap), I covered that in our previous article on integration testing with databases. We’ll take that as a given here and focus on the practical infrastructure.

The Infrastructure Problem

My relationship with test databases has a history. Not a proud one.

The first chapter was a single shared test database — one server, every developer and every CI run pointed at the same thing. You can probably guess how that went. Tests stomping on each other, flaky results depending on who else was running at the same time, data left behind from a previous run poisoning the next one. “Why is this failing on CI but passing locally?” was a regular conversation.

So developers were asked to point their connection strings at their own database. That helped. But CI builds still shared the same server, and when multiple pull requests triggered builds simultaneously — whoops. We bottlenecked CI and just accepted the occasional failure as a known issue. Hit retry. Move on. That worked… for a while.

Then our CI builds moved from on-prem Team Foundation Server to cloud-based Azure DevOps. Now we were poking holes in the firewall just to get tests to run. It was a mess.

LocalDB was a big step up. Lightweight SQL Server, ships with Visual Studio, available on the Windows cloud agents for Azure DevOps with zero configuration — it works right out of the box, and every developer gets their own isolated instance.

The catch: on Windows.

When I needed to work on my MacBook Air, LocalDB wasn’t an option. I ended up running SQL Server in Docker — which worked, but required manual setup on every machine. Then I moved onto a project using PostgreSQL, and now I had two completely different setup stories depending on the project.

“Does everyone have the right Docker image pulled? Is your connection string pointing at the right port?” Not fun questions to answer every time someone new joins the team or spins up a CI runner.

Every developer machine, every CI environment, every onboarding session needed the right database server running the right way before tests could pass. That friction compounds.

I’m not the first to walk this road. Steve Smith (ardalis) mapped the whole journey in Running Integration Tests in Build Pipelines with a Real Database — the same dead ends I’d hit (no integration tests, the in-memory provider, a shared test database) and the same destination he lands on: a Dockerized database deployed right alongside your tests. His approach stands up SQL Server with a hand-written docker-compose.yml, a Dockerfile, and a wait script that polls port 1433 until the database is listening. It’s a great pattern, and it works.

What follows is the next step down that road. Everything those config files orchestrate — pulling the image, waiting for the database to come online, handing your app a connection string — a library now handles for you.

Enter Testcontainers ⚡

Testcontainers is a .NET library that spins up Docker containers programmatically from your test code. You describe what you need, and it handles the rest.

var container = new PostgreSqlBuilder()
    .WithImage("postgres:16-alpine")
    .WithDatabase("testdb")
    .Build();

await container.StartAsync();

var connectionString = container.GetConnectionString();

No manual Docker setup. No “make sure you have the right version installed.” No environment-specific configuration files shared across the team.

And remember that wait script Steve needed to poll port 1433? You don’t write one. The database modules ship with readiness checks built in — StartAsync() doesn’t return until the container is up and the database is actually accepting connections. By the time GetConnectionString() hands you a connection string, it points at something that’s ready to talk to.

Here’s what made me an immediate convert: it’s cross-platform. Works wherever Docker runs — Windows, macOS, Linux, your CI pipeline. Every developer and every CI runner gets the exact same database version, so those “works on my machine” discrepancies just disappear. And if you need PostgreSQL on one project and SQL Server on the next, you swap the builder. Same pattern, different class.

Worth a quick note: Testcontainers isn’t limited to databases. Redis, RabbitMQ, anything your application depends on can be managed the same way. That’s a topic for another day, but it’s good to know the pattern extends well beyond what we’re covering here.

Setting Up the WebApplicationFactory

You don’t spin up containers in individual test methods — that would be painfully slow. I’ve never needed that level of test isolation. The pattern is one container per test run, shared across tests, started once and torn down at the end.

Install the package:

dotnet add package Testcontainers.PostgreSql
# or for SQL Server:
# dotnet add package Testcontainers.MsSql

Then create a CustomWebApplicationFactory that extends WebApplicationFactory<TEntryPoint>:

using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.Hosting;
using Testcontainers.PostgreSql;

public class CustomWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithDatabase("testdb")
        .WithUsername("testuser")
        .WithPassword("testpass")
        .Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // overriding the appsettings.json connection string with the dynamic one from Testcontainers.
        builder.UseSetting("ConnectionStrings:DefaultConnection", _container.GetConnectionString());
        builder.UseEnvironment("Testing");
    }

    public async ValueTask InitializeAsync()
    {
        await _container.StartAsync();
        await RunMigrationsAsync();
        await SeedDataAsync();
    }

    public override async ValueTask DisposeAsync()
    {
        await _container.DisposeAsync();
        await base.DisposeAsync();
    }

    private async Task RunMigrationsAsync()
    {
        using var scope = Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await context.Database.MigrateAsync();
    }

    private async Task SeedDataAsync()
    {
        using var scope = Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        // Seed ALL the baseline data your tests rely on here — categories,
        // lookup tables, reference users, feature flags, and so on.
        context.Categories.Add(new Category { Id = 1, Name = "Electronics" });
        // context.Add(...);
        // context.Add(...);

        await context.SaveChangesAsync();
    }
}

WebApplicationFactory<TEntryPoint> bootstraps your entire application the same way it runs in production — whatever’s in Program.cs gets wired up. ConfigureWebHost gives us a hook to swap out just what we need for testing. In this case, we’re pointing the database at our container instead of wherever production points. Everything else stays exactly the same.

You’ll see other guides take a heavier-handed approach here — reaching into the service collection to remove the registered DbContextOptions<T> and re-registering the DbContext against the container. That works, but it’s more ceremony than I want. Overriding the connection string in configuration lets the app wire itself up exactly as it normally would; I’m only changing where it points, not how it’s built.

One thing worth clarifying: TEntryPoint doesn’t have to be Program specifically. The docs say it just needs to be any public type in the entry point assembly. You may have seen older guides tell you to add public partial class Program { } at the bottom of Program.cs to expose it — I certainly ran across that and had no idea why. Now you know: it was a workaround for exactly this. Any existing public class in your API project works just as well.

SeedDataAsync is where baseline reference data goes — the categories, lookup tables, and other rows your tests assume already exist. Keep this to the shared data every test needs; data specific to a single test is better created inside that test.

IAsyncLifetime handles the container lifecycle. The container starts once, migrations run once, the baseline data is seeded once, and everything tears down cleanly when the tests are done. (This uses the xUnit v3 signatures, where IAsyncLifetime works through ValueTask and IAsyncDisposable — which is why we can cleanly override the base factory’s DisposeAsync instead of hiding it.)

Sharing the Container Across Tests

The factory starts one container and shares it across the entire test collection. xUnit’s collection fixtures make this straightforward:

[CollectionDefinition("Api")]
public class ApiCollection : ICollectionFixture<CustomWebApplicationFactory> { }
[Collection("Api")]
public class ProductTests
{
    private readonly CustomWebApplicationFactory _factory;
    private readonly HttpClient _client;

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

    [Fact]
    public async Task CreateProduct_ValidData_ReturnsCreatedProductId()
    {
        var request = new CreateProductRequest
        {
            Name = "Gaming Laptop",
            Price = 1299.99m,
            CategoryId = 1
        };

        var response = await _client.PostAsJsonAsync("/api/products", request);

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        var productId = await response.Content.ReadFromJsonAsync<int>();
        productId.Should().BeGreaterThan(0);
    }
}

Tests run through HttpClient — real HTTP requests through the full ASP.NET Core pipeline. Routing, model binding, validation, middleware, authentication, database, serialization — all of it. These tests exercise the same surface area your users hit.

As the suite grows, the repetitive bits — authenticating the client, seeding data, deserializing responses — are worth pulling into reusable helpers. I collected a few of mine in ASP.NET Core integration test helpers.

Using SQL Server with Testcontainers

The setup is identical — just swap the builder:

dotnet add package Testcontainers.MsSql
private readonly MsSqlContainer _container = new MsSqlBuilder()
    .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
    .Build();

Everything else stays the same.

Where This Leaves You

At this point you have a real database container spinning up automatically for every test run — on any machine, in any CI environment, with zero manual setup. No LocalDB limitations. No Docker configuration shared on a wiki somewhere.

What you don’t have yet is a clean database between tests. One test creates a product, the next test sees it. That isolation problem grows fast as your suite gets bigger — and I went down at least one bad road trying to solve it before I found the right answer.

That’s the story in the next article in this series — stay tuned. It’s a good one.

Happy testing, folks!

Resources