.NET Tools

How to use Testcontainers with .NET Unit Tests

With Testcontainers, you can spin up and tear down Docker containers as part of your automated tests. For example, if you need a database, you don’t need to mock it: your xUnit, NUnit, and MSTest code can launch an actual database against which you can validate your code. Read on to find out how!

While testing is a settled debate (all developers should be testing), the question of “how” is still contentious. Many developer discussions around testing focus on three characteristics: Speed, Confidence, and Maintainability. When building out your unit test suite, it’s essential to contemplate questions around these characteristics.

Regarding speed, many developers lean on mocking frameworks such as Moq, NSubstitute, or JustMock to keep tests fast. Mocking libraries allow developers to build a substitute for a dependency with behavioral assumptions. For example, a database call may always return a set of records. These calls can be intercepted in-process and handled in the scope of the test run.

Mocking is fast but typically limited by a developer’s understanding of the underlying technology. Incorrect assumptions can lead to unforeseen errors in production environments. That’s typical because mocks can drift from the actual dependency implementation, and thus, the confidence level of mocked tests is generally lower than running against the dependency itself.

On the opposite side of the testing spectrum, working with a dependency instance, such as a database, can give us a higher sense of confidence. Remember, developers are testing on top of complex systems, and the behaviors of those systems can change subtly over time. Working with the “real” dependency allows us to catch these issues early in the development lifecycle.

The drawback when working with dependency instances is that they can be a burden to manage and considerably slower than running tests in memory. Developers measure these tests in seconds while mocked tests in milliseconds. Generally, these tests can be more “accurate” and produce higher confidence levels, but they cost time and resources.

Our final characteristic brings us to maintainability. Luckily for .NET developers, applications have become much easier to test with newer framework iterations. There are multiple testing libraries, containerized dependencies, and modern language features that reduce much of the hassle of managing a unit test suite.

In this post, we’ll be exploring Testcontainers within .NET and hopefully give you a few answers to the questions previously proposed. By the end of this post, you should understand the Testcontainers library, some strategies around dependency isolation, and how easy it can be to test existing ASP.NET Core applications.

What is Testcontainers?

A decade ago, it was challenging, if not impossible, to automate the creation of application dependencies such as database servers and others with code. Luckily for most modern developers, the idea of containerization has become mainstream.

With containerization, developers can use technologies like Docker to create instances of databases, message brokers, cache services, and just about anything that runs within a container. While containerization has undoubtedly eased the pain of dependencies, creating containers is typically command-line (CLI) based. While the CLI is accessible to developers, it’s not as friendly as possible. Enter, Testcontainers.

Testcontainers is an open-source framework that allows developers to leverage the Docker API to create dependencies through the languages they know and love. With first-class support for Java, Go, Node.Js, Python, Rust, Haskell, Ruby, Clojure, and of course, .NET. Testcontainers turn traditionally heavy-weight and burdensome dependencies into lightweight and throwaway ones. Using Testcontainers allows developers to test against the dependency without fear of incorrect behavior assumptions and limited mocks.

In addition to providing an impressive API for image and container management, the Testcontainers project also provides APIs for common dependencies such as PostgreSQL, Redis, RabbitMq, Azurite, and more. These libraries provide an out-of-the-box experience tailored for each dependency. The libraries can make the transition from mocks to container-based testing more straightforward.

Let’s look at the “Hello, World” of Testcontainers. We’ll use xUnit as my testing framework for the following examples, but these samples will also work in NUnit.

Hello, Testcontainers: Introduction

Note: All code is available on GitHub. Code samples use .NET 8 and C# 12.

To get started, you’ll need to install the Testcontainers NuGet package in a new Unit Test Project. You’ll also need Docker for Desktop installed and running in your development environment. Remember, Testcontainers works with images and manages containers through Docker.

dotnet add package Testcontainers

Next, you’ll create a new test class. Name the C# class anything you’d like. For the purposes of this post, I called my test class HttpTest. We’ll leverage xUnit’s IAsyncLifetime interface to support async/await calls before and after each test.

using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;

namespace TestingWithContainers;

public class HttpTest : IAsyncLifetime
{
    private readonly IContainer container;

    public HttpTest()
    {
        container = new ContainerBuilder()
            // Set the image for the container to "testcontainers/helloworld:1.1.0".
            .WithImage("testcontainers/helloworld:1.1.0")
            // Bind port 8080 of the container to a random port on the host.
            .WithPortBinding(8080, true)
            // Wait until the HTTP endpoint of the container is available.
            .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(8080)))
            // Build the container configuration.
            .Build();
    }

    [Fact]
    public async Task Can_Call_Endpoint()
    {
        var httpClient = new HttpClient();

        var requestUri =
            new UriBuilder(
                Uri.UriSchemeHttp,
                container.Hostname,
                container.GetMappedPublicPort(8080),
                "uuid"
            ).Uri;

        var guid = await httpClient.GetStringAsync(requestUri);

        Assert.True(Guid.TryParse(guid, out _));
    }

    public Task InitializeAsync()
        => container.StartAsync();

    public Task DisposeAsync()
        => container.DisposeAsync().AsTask();
}

The sample uses an image named testcontainers/helloworld:1.1.0, available on the Docker registry. If you do not have this image, Testcontainers will download it to your local environment. When creating the container, we bind the 8080 container port to a random host port. Finally, we set a WaitStrategy, which ensures our test only begins executing once the container is ready.

With xUnit, the IAsyncLifetime methods allow us to start and stop the container. Once the container is activated, the test runner can execute the Can_Call_Endpoint method. From here, it’s a straightforward test.

Congratulations! You’ve just written and run your first Testcontainers-powered test.

Let’s look at how to use Testcontainers and xUnit with different isolation strategies to increase the confidence of your unit tests and speed up test run times.

Test Isolation Strategies with xUnit

When working with xUnit, you can think of three levels of isolation: Test, Class, and Collection. With Testcontainers, a test-level isolation strategy is essential to balancing run times and your confidence in the results.

Note: The following examples have added PostgreSQL, Dapper, and Marten as real-world test dependencies. We also use the package TestContainers.PostgreSql for creating my PostgreSQL container.

Container-per-test Strategy

In this strategy, we’ll create and run an isolated container for each test, ensuring no other process can mutate the dependency. Let’s take a look.

using Dapper;
using JasperFx.Core;
using Marten;
using Npgsql;
using Testcontainers.PostgreSql;
using Weasel.Core;
using Xunit.Abstractions;

namespace TestingWithContainers;

public class DatabaseContainerPerTest(ITestOutputHelper output) 
    : IAsyncLifetime
{
    // this is called for each test, since each test
    // instantiates a new class instance
    private readonly PostgreSqlContainer container = 
        new PostgreSqlBuilder()
        .Build();

    private string connectionString = string.Empty;

    [Fact]
    public async Task Database_Can_Run_Query()
    {
        await using NpgsqlConnection connection = new(connectionString);
        await connection.OpenAsync();

        const int expected = 1;
        var actual = await connection.QueryFirstAsync<int>("SELECT 1");

        Assert.Equal(expected, actual);
    }

    [Fact]
    public async Task Database_Can_Select_DateTime()
    {
        await using NpgsqlConnection connection = new(connectionString);
        await connection.OpenAsync();

        var actual = await connection.QueryFirstAsync<DateTime>("SELECT NOW()");
        Assert.IsType<DateTime>(actual);
    }

    [Fact]
    public async Task Can_Store_Document_With_Marten()
    {
        await using NpgsqlConnection connection = new(connectionString);
        var store = DocumentStore.For(options => {
            options.Connection(connectionString);
            options.AutoCreateSchemaObjects = AutoCreate.All;
        });

        int id;
        {
            await using var session = store.IdentitySession();
            var person = new Person("Khalid");
            session.Store(person);
            await session.SaveChangesAsync();

            id = person.Id;
        }

        {
            await using var session = store.QuerySession();
            var person = session.Query<Person>().FindFirst(p => p.Id  id);
            Assert.NotNull(person);
        }
    }

    public async Task InitializeAsync()
    {
        await container.StartAsync();
        connectionString = container.GetConnectionString();
        output.WriteLine(container.Id);
    }

    public Task DisposeAsync() => container.StopAsync();
}

Each Fact will have its isolated instance of a container, which assures that writes to our database will not affect other tests. When running these tests in my development environment, all three tests take about 13 seconds to execute. That’s alright, but we can do better. Let’s move on to a container within a test class.

Container-per-class Strategy

xUnit has a generic interface called IClassFixture, which allows us to define dependencies across all tests within a test class. We’ll need a Fixture, a setup class for shared resources across tests to accomplish our goal.

namespace TestingWithContainers;

// ReSharper disable once ClassNeverInstantiated.Global
public class DatabaseFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer container = 
        new PostgreSqlBuilder()
            .Build();

    public string ConnectionString => container.GetConnectionString();
    public string ContainerId => $"{container.Id}";

    public Task InitializeAsync() 
        => container.StartAsync();

    public Task DisposeAsync() 
        => container.DisposeAsync().AsTask();
}

You’ll notice the code is almost identical to the code in the previous sample. Now, let’s modify our test class to use the IClassFixture interface.

using Dapper;
using JasperFx.Core;
using Marten;
using Npgsql;
using Weasel.Core;
using Xunit.Abstractions;

namespace TestingWithContainers;

public class DatabaseContainerPerTestClass(DatabaseFixture fixture, ITestOutputHelper output) 
    : IClassFixture<DatabaseFixture>, IDisposable
{
    [Fact]
    public async Task Database_Can_Run_Query()
    {
        await using NpgsqlConnection connection = new(fixture.ConnectionString);
        await connection.OpenAsync();

        const int expected = 1;
        var actual = await connection.QueryFirstAsync<int>("SELECT 1");

        Assert.Equal(expected, actual);
    }

    [Fact]
    public async Task Database_Can_Select_DateTime()
    {
        await using NpgsqlConnection connection = new(fixture.ConnectionString);
        await connection.OpenAsync();

        var actual = await connection.QueryFirstAsync<DateTime>("SELECT NOW()");
        Assert.IsType<DateTime>(actual);
    }

    [Fact]
    public async Task Can_Store_Document_With_Marten()
    {
        await using NpgsqlConnection connection = new(fixture.ConnectionString);
        var store = DocumentStore.For(options => {
            options.Connection(fixture.ConnectionString);
            options.AutoCreateSchemaObjects = AutoCreate.All;
        });

        int id;
        {
            await using var session = store.IdentitySession();
            var person = new Person("Khalid");
            session.Store(person);
            await session.SaveChangesAsync();

            id = person.Id;
        }

        {
            await using var session = store.QuerySession();
            var person = session.Query<Person>().FindFirst(p => p.Id  id);
            Assert.NotNull(person);
        }
    }

    public void Dispose()
        => output.WriteLine(fixture.ContainerId);
}

These tests are identical to the previous sample, except they now use the fixture instance across all runs. Running this test class, we now see that our tests run in about 9 seconds. That’s a 4-second improvement, which will scale as our test suite grows.

The obvious drawbacks to this strategy are that tests may inadvertently interfere with other tests’ assumptions. However, some techniques might help get the most out of this approach.

Grouping “read-based” tests in these classes will net you the most performance while keeping confidence levels high. Additionally, you can use the constructor and IDisposable interface to clean up and reset the state within a shared container, but this may limit parallelization. Finally, you can take an app-based approach to isolation by creating tenants using database schemas. All these techniques will vary based on your particular use case.

Now, let’s take a look at the container-per-collection strategy.

Container-per-collection strategy

xUnit allows you to create “collections”, a logically-grouped set of tests that share resources. In the scope of this post, we want to share our container across our collection. Let’s take a look at this strategy in action.

using Dapper;
using JasperFx.Core;
using Marten;
using Npgsql;
using Weasel.Core;
using Xunit.Abstractions;

namespace TestingWithContainers;

[CollectionDefinition(nameof(DatabaseCollection))]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>;

public static class DatabaseContainerPerCollection
{
    [Collection(nameof(DatabaseCollection))]
    public class First(DatabaseFixture fixture, ITestOutputHelper output) : IDisposable
    {
        [Fact]
        public async Task Database_Can_Run_Query()
        {
            await using NpgsqlConnection connection = new(fixture.ConnectionString);
            await connection.OpenAsync();

            const int expected = 1;
            var actual = await connection.QueryFirstAsync<int>("SELECT 1");

            Assert.Equal(expected, actual);
        }

        [Fact]
        public async Task Database_Can_Select_DateTime()
        {
            await using NpgsqlConnection connection = new(fixture.ConnectionString);
            await connection.OpenAsync();

            var actual = await connection.QueryFirstAsync<DateTime>("SELECT NOW()");
            Assert.IsType<DateTime>(actual);
        }

        [Fact]
        public async Task Can_Store_Document_With_Marten()
        {
            await using NpgsqlConnection connection = new(fixture.ConnectionString);
            var store = DocumentStore.For(options => {
                options.Connection(fixture.ConnectionString);
                options.AutoCreateSchemaObjects = AutoCreate.All;
            });

            int id;
            {
                await using var session = store.IdentitySession();
                var person = new Person("Khalid");
                session.Store(person);
                await session.SaveChangesAsync();

                id = person.Id;
            }

            {
                await using var session = store.QuerySession();
                var person = session.Query<Person>().FindFirst(p => p.Id  id);
                Assert.NotNull(person);
            }
        }

        public void Dispose() => output.WriteLine(fixture.ContainerId);
    }

    [Collection(nameof(DatabaseCollection))]
    public class Second(DatabaseFixture fixture, ITestOutputHelper output) : IDisposable
    {
        [Fact]
        public async Task Database_Can_Run_Query()
        {
            await using NpgsqlConnection connection = new(fixture.ConnectionString);
            await connection.OpenAsync();

            output.WriteLine("Hi! 👋");

            const int expected = 1;
            var actual = await connection.QueryFirstAsync<int>("SELECT 1");

            Assert.Equal(expected, actual);
        }

        [Fact]
        public async Task Database_Can_Select_DateTime()
        {
            await using NpgsqlConnection connection = new(fixture.ConnectionString);
            await connection.OpenAsync();

            var actual = await connection.QueryFirstAsync<DateTime>("SELECT NOW()");
            Assert.IsType<DateTime>(actual);
        }

        [Fact]
        public async Task Can_Store_Document_With_Marten()
        {
            await using NpgsqlConnection connection = new(fixture.ConnectionString);
            var store = DocumentStore.For(options => {
                options.Connection(fixture.ConnectionString);
                options.AutoCreateSchemaObjects = AutoCreate.All;
            });

            int id;
            {
                await using var session = store.IdentitySession();
                var person = new Person("Khalid");
                session.Store(person);
                await session.SaveChangesAsync();

                id = person.Id;
            }

            {
                await using var session = store.QuerySession();
                var person = session.Query<Person>().FindFirst(p => p.Id  id);
                Assert.NotNull(person);
            }
        }

        public void Dispose() => output.WriteLine(fixture.ContainerId);
    }
}

In this sample, we’ve duplicated our previous tests to show the reuse of containers across two test classes. The tests are identical to the earlier examples, but now they run for a total of 6 times. Running these tests, we now get a total execution time of 4 seconds. That’s double the work in a third of the time.

This strategy has the same issues as our previous strategy of Container-per-collection, so you’ll need to take appropriate precautions against mutating assumptions across tests.

So far, so good, but what about using these strategies with an ASP.NET Core application? Well, that’s up next.

Testcontainers with ASP.NET Core Apps

ASP.NET Core is the most common programming model used by .NET developers today, and testing it has never been easier. We’ll use the Container-per-collection strategy in this sample, but you may adjust the example to your specific use case. Let’s first look at the web application we’ll be testing.

using System.Data.Common;
using Dapper;
using Npgsql;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<DbConnection>(static sp => {
    var config = sp.GetRequiredService<IConfiguration>();
    var connectionString = config.GetConnectionString("database");
    var connection = new NpgsqlConnection(connectionString);
    connection.Open();
    return connection;
});

var app = builder.Build();

app.MapGet("/", () => "Hello World!");
app.MapGet("/database", async (DbConnection db, string? make) =>
{
    if (make is null) 
        return Results.NotFound();

    var car = await db.QueryFirstAsync<Car>(
        "select * from Cars where make = @make",
        new { make }
    );

    return car is not null 
        ? Results.Ok(car) 
        : Results.NotFound();
});

app.Run();


public class Car
{
    public int Id { get; set; }
    public string Make { get; set; }
    public string Model { get; set; }
    public int Year { get; set; }
}

The web application utilizes Dapper and a PostgreSQL instance to read Car records from a table. Most importantly, the application finds the connection string in the configuration instance, which reads from all the default locations.

using System.Net.Http.Json;
using Dapper;
using Microsoft.AspNetCore.Mvc.Testing;
using Npgsql;

namespace TestingWithContainers;

public class WebAppWithDatabase(DatabaseFixture fixture) 
    : IClassFixture<DatabaseFixture>, IAsyncLifetime
{
    [Fact]
    public async Task Get_Information_From_Database_Endpoint()
    {
        var factory = new WebApplicationFactory<Program>()
            .WithWebHostBuilder(host => {
                // database connection from TestContainers
                host.UseSetting(
                    "ConnectionStrings:database", 
                    fixture.ConnectionString
                );
            });

        var client = factory.CreateClient();
        var actual = await client.GetFromJsonAsync<Car>("/database?make=Honda");

        Assert.Equal(expected: "Civic", actual?.Model);
    }

    public async Task InitializeAsync()
    {
        var connection = new NpgsqlConnection(fixture.ConnectionString);
        // let's migrate a table here and insert values
        //
        // Note: if you're using EF Core, this is where you'd run your database migrations
        // there are also other migration frameworks like FluentMigrator or Flywheel that you might try.
        // For the sake of simplicity, executing SQL here is fine.
        await connection.ExecuteAsync(
            // lang=sql
            """
            DO $$
            BEGIN
                IF NOT EXISTS (SELECT FROM pg_catalog.pg_tables WHERE schemaname = 'public' AND tablename = 'cars') THEN
                    CREATE TABLE Cars (
                        id SERIAL PRIMARY KEY,
                        make VARCHAR(255),
                        model VARCHAR(255),
                        year INT
                    );

                    INSERT INTO Cars (make, model, year) VALUES
                    ('Toyota', 'Corolla', 2020),
                    ('Honda', 'Civic', 2020),
                    ('Ford', 'Focus', 2020);
                END IF;
            END $$;
            """
        );
    }

    public Task DisposeAsync() => Task.CompletedTask;
}

This test uses the Microsoft.AspNetCore.Mvc.Testing library to build our ASP.NET Core application and allow us to inject our container as a dependency, specifically, the container’s connection string. We can also migrate our database and set the initial state of our database. From there, it’s as straightforward as calling our endpoints. Very nice!

We highly recommend this approach to testing ASP.NET Core applications, as the HTTP protocol can be incredibly complex and nuanced. Additionally, using Testcontainers, you can be confident that dependencies will behave as intended.

In the next section, let’s use Testcontainers with a custom image.

Creating Images with Testcontainers

While we don’t recommend this approach for .NET developers, there are situations where you have a custom container and would like to test your application within the context of Docker. This approach can be plodding as Docker must create all the required images. The typical .NET Dockerfile creates multiple build steps, as you’ll see.

Let’s use an existing Dockerfile to create and test an ASP.NET Core application. We found that the Dockerfile must be at the root of your solution for this approach to work correctly.

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["TestingWithContainers.WebApp/TestingWithContainers.WebApp.csproj", "TestingWithContainers.WebApp/"]
RUN dotnet restore "TestingWithContainers.WebApp/TestingWithContainers.WebApp.csproj"
COPY . .
WORKDIR "/src/TestingWithContainers.WebApp"
RUN dotnet build "TestingWithContainers.WebApp.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "TestingWithContainers.WebApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "TestingWithContainers.WebApp.dll"]

Next, let’s write our test to build an image and run it as a container instance.

using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Images;

namespace TestingWithContainers;

public class CustomContainerTest : IAsyncLifetime
{
    private IFutureDockerImage image;
    private IContainer container;

    public async Task InitializeAsync()
    {
        image = new ImageFromDockerfileBuilder()
            .WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), string.Empty)
            .WithDockerfile("Dockerfile")
            .WithCleanUp(true)
            .Build();

        // create image from Dockerfile
        await image.CreateAsync();

        container = new ContainerBuilder()
            .WithImage(image)
            .WithPortBinding(80, assignRandomHostPort: true)
            // use environment variables to add configuration options
            .WithEnvironment("ASPNETCORE_URLS", "http://+:80")
            .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPath("/")))
            .Build();

        // build container
        await container.StartAsync();
    }

    public async Task DisposeAsync()
    {
        await image.DisposeAsync();
        await container.DisposeAsync();
    }

    [Fact]
    public async Task Can_Call_Endpoint()
    {
        var httpClient = new HttpClient();

        var requestUri =
            new UriBuilder(
                Uri.UriSchemeHttp,
                container.Hostname,
                container.GetMappedPublicPort(80),
                "/"
            ).Uri;

        var expected = "Hello World!";
        var actual = await httpClient.GetStringAsync(requestUri);

        Assert.Equal(expected, actual);
    }
}

This sample introduces you to the ImageFromDockerfileBuilder class, which allows us to find and use an existing Dockerfile. From here, we can use that same image to create a container using ContainerBuilder, providing vital information like port bindings, environment variables, and our wait strategy. Once we’ve set up our test class, we can execute our test similarly to our previous examples.

Running this single test with all base images already downloaded still comes at 23 seconds of execution time. For .NET developers, we don’t see the advantages to this approach unless you have container-specific dependencies that must run within the container. That said, Testcontainers provides a full-featured set of APIs that allow this approach to be possible, and you should evaluate its usefulness based on your use cases.

Conclusion

While not a complete set of all Testcontainer features, We’ve outlined some common approaches for your unit tests. As mentioned, all these Testcontainer samples are available on a GitHub repository so that you can try them out in your development environment.

Testcontainers is an amazingly robust library that helps all developers increase their confidence in their delivered solutions. The team around Testcontainers has also gone ahead and built libraries around common dependencies to make it easier to switch out mocks for actual implementations. We’re impressed and hope you try this library in your test suites.

If you’re new to Testcontainers or unit testing in general, We’d love to hear your questions below. If you’re a longtime user, We’d also love to read about your strategies for increasing confidence in your tests while keeping execution of test suites fast. And, as always, thanks for reading and being a part of the JetBrains community.

image credit: Girl with red hat

image description

Discover more