How to Use TestContainers in .Net

At some point in your development lifecycle, you will need to test that your system can integrate with another system, whether it be another API, a database, or caching service, for example. This can be a laborious task of spinning up other servers h...

Mar 25, 2025 - 21:13
 0
How to Use TestContainers in .Net

At some point in your development lifecycle, you will need to test that your system can integrate with another system, whether it be another API, a database, or caching service, for example. This can be a laborious task of spinning up other servers hosting the 3rd party API replica, or permanently hosting a SQL database seeded with test data.

In this article, I’ll teach you how to use the TestContainers library to make running integration tests much easier and more manageable.

Table of Contents

Prerequisites

  • Understanding of Docker

  • Understanding of xUnit and testing

  • Installation of the following packages:

    • TestContainers

    • TestContainers.MsSql

    • xUnit

    • >= .Net 8

    • FluentAssertions

    • Microsoft.Data.SqlClient

What Is TestContainers?

TestContainers is an open source library that provides you with easily disposable container instances for things like database hosting, message brokers, browsers and more – basically anything that can run in a Docker container.

It removes the necessity to maintain hosted environments for testing in the cloud or on local machines. As long as the user’s machine and CI/CD host supports Docker, the testContainer tests can easily be run.

How Does It All Work?

You define the image you’re wanting to utilise, and specify a configuration.

The TestContainer library spins up a Docker Container with the configured image.

Provides Connection Details

After starting the container, TestContainers exposes connection strings (for example, a database connection URL), so your tests can use the real service, rather than having to configure this yourself.

Cleans Up Automatically

When the test finishes, TestContainers removes the container automatically, ensuring no leftover resources. This is one of the best things about using TestContainers: all the creation, tear down, and container setup is handled within the library itself, making it perfect for use within delivery pipelines.

How to Set Up Your First Test

For the purpose of this tutorial, we’re going to keep things simple, and only use a MS Sql Server image.

The first thing we’re going to do is configure our Microsoft SQL Server Docker container via the TestContainer fluid API.

Create your test class like below:

public class IntegrationTests: IAsyncLifetime 
{
    private MsSqlContainer _container;
    private FakeLogger _logger

    public async Task InitializeAsync()
    {
           _container = new MsSqlBuilder()
                .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
                .WithPassword("P@ssw0rd123")
                .WithPortBinding(1443)
                .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
                .Build();

            _logger = new FakeLogger();
    }

    public async Task DisposeAsync() => await _container.DisposeAsync();
}

Here we’re using xUnit’s IAsyncLifetime interface. It’s an interface in xUnit that provides a way to handle async setup and teardown for test classes. It's useful when you need to initialise and clean up resources asynchronously. We’re using the InitializeAsync() to setup and define our Microsoft SQL Database container as well as starting the container, then using the DisposeAsync() method to stop and dispose of our container.

Explanation of Builder Methods

  • WithImage(): this allows us to specify the image we want Docker to pull down and run. We’ve opted for the latest version of SQL Server 2022.

  • WithPassword(): This allows us to specify the password for the database (when creating most databases, a password is normally required).

  • WithPortBinding(): This allows us to specify both the hosting port number on your machine, as well as the container port number

  • WithWaitStrategy(): Here we can specify a wait strategy, which informs our container to wait for a condition before the container is ready to use. This is important because some services (like databases or APIs) take time to fully start up.

  • Build()" This is the command that builds the test container based on the configuration. This does not run or start the container – you can do this using the container.StartAsync() method as mentioned previously.

Why Is WithWaitStrategy() Needed?

By default, TestContainers assumes the container is ready as soon as it starts running. But some services might:

  • Take time to initialize.

  • Require a specific log message before they are ready.

  • Need a port to be accessible before you can connect.

Using WithWaitStrategy(), you can customise how TestContainers waits before considering the container "ready."

Adding the Test

public class IntegrationTests: IAsyncLifetime 
{
    private MsSqlContainer _container;
    private FakeLoger _logger;

    public async Task InitializeAsync()
    {
           _container = new MsSqlBuilder()
                .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
                .WithPassword("P@ssw0rd123")
                .WithPortBinding(1443)
                .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
                .Build();

            await _container.StartAsync();
            _logger = new FakeLogger();
    }

    public async Task DisposeAsync() => await _container.DisposeAsync();

    [Fact]
    public async Task Test_Database_Connection()
    {
        var connectionString = _container.GetConnectionString();
        using var conn = new SqlConnection(connectionString);
        await conn.OpenAsync();

        Assert.True(conn.State == System.Data.ConnectionState.Open);
    }
}

The above test, although it’s simple, illustrates how easy it is to spin up a container and create a simple test. The above test will work, but it can lead to low performing tests and high usage of machine resource when not used correctly. Let me explain:

Using IAsyncLifetime is necessary, as we’re calling async setup methods (StartAsync), for example. But the InitializeAsync() / DisposeAsync() methods when situated in a test class are run before and after every test (Fact in xUnit).

This means that every time a test begins, it is:

  • creating a brand new Docker container,

  • pulling the MS Sql image,

  • creating the DB,

  • running the tests, and

  • tearing down the container.

You can test this by copying and pasting the above Test_Database_Connection() test multiple times, adding a number to each duplicate test (to keep the compiler happy), and opening Docker Desktop. Running all the tests, you will see a new container (with a different name) being created for each test run.

Now, this can be acceptable if you have a limited number of tests in your test class. But it can have negative outcomes on test classes with a larger number of tests, meaning test maintenance and planning is key. It’s useful, though, when you want to make sure that the database is in a completely clean state before each test, ensuring no data contamination from other tests running.

Key Behaviors of IAsyncLifetime in a Test Class

When your test class implements IAsyncLifetime, xUnit's default behaviour is:

1. Creates a new instance of the test class for each test method.
2. Calls InitializeAsync() before each test.
3. Calls DisposeAsync() after each test.

What Does This Mean for TestContainers?

  • In our case, since InitializeAsync() sets up a new container, a new container is created for each test.

  • DisposeAsync() stops the container after each test finishes.

  • Ensures a completely fresh database state for every test, avoiding data contamination.

  • Is slow and resource-intensive, especially if you have many test methods.

A more visual look on a test class could look like this: