In software development, it is essential to sufficiently test the written code. In addition to Unit Tests, which individually test single components, integration tests examine the interaction between multiple components. However, when there are external dependencies such as a database or a message broker, testing becomes more complex.

To ensure that external systems are reproducibly and predictably available in integration tests, they can be run in Docker containers. A simple setup of such service containers is enabled by the library Testcontainers.

The example code for this blog post is available on GitHub: https://github.com/davull/demo-docker-testcontainers

In a subsequent blog post, I will show how to implement integration tests using Docker Compose and Azure Service Containers.

Integration Tests

Integration tests are designed to examine the interaction of multiple components of a software system. This can involve individual functional units but also a complete pass-through of the application. For example, for a web API, one might send an HTTP request to the application, manipulate data in a database, and then return the result stored in a Redis cache back to the client.

While the behavior of external systems can be emulated using mocks, integration tests truly add value when they interact with real systems. This moves us closer to a production environment, allowing for the discovery of problems that would not be visible with mocks.

Most systems can now be operated in a Docker container. If a pre-configured image is not available, it is usually straightforward to create one’s own.

This containerization of applications can be utilized in testing to start the required systems in a defined state before each test run, such as in a CI/CD pipeline. After the tests are executed, the containers are simply stopped and deleted.

Testcontainers

The Testcontainers project provides libraries for many programming languages to simplify the setup and execution of Docker containers in tests. For .NET, there is a corresponding NuGet package available: Testcontainers for .NET.

A Testcontainer is simply defined before executing the test methods, started, and then stopped and deleted afterwards. The ContainerBuilder class offers a Fluent API to configure the containers.

var container = new ContainerBuilder()
    .WithImage("mariadb:11.3")
    .WithHostname("mariadb-test-container")
    .Build();

await container.StartAsync();

// run integration tests

await container.StopAsync();

To be able to run the tests locally, Docker must be installed on the computer and the Docker service must be running.

Best practice is to map container ports to random host ports to avoid conflicts with other running services and containers.

var containerBuilder = new ContainerBuilder()
    .WithPortBinding(3306, assignRandomHostPort: true);

var port = container.GetMappedPublicPort(3306);

To wait for the availability of a service within the container after startup, Testcontainers provides a variety of Wait strategies. For example, you can wait for the container’s health status or check the availability of any TCP port. Waiting for an HTTP endpoint is also possible.

// Wait for MySQL to be ready
var containerBuilder = new ContainerBuilder()
    .WithImage("mariadb:11.3")
    .WithWaitStrategy(Wait.ForUnixContainer()
        .UntilPortIsAvailable(3306));

// Wait for HTTP service to be ready
var containerBuilder = new ContainerBuilder()
    .WithImage("productservice:latest")
    .WithWaitStrategy(Wait.ForUnixContainer()
        .UntilHttpRequestIsSucceeded(strategy => strategy
            .ForPort(443)
            .ForPath("/api/products")
            .WithBasicAuthentication("user", "password")));

Many container images can be configured via environment variables. These can easily be passed to a Testcontainer using the WithEnvironment(env) method.

var env = new Dictionary<string, string>
{
    { "MYSQL_ROOT_PASSWORD", "some-password" },
    { "MYSQL_DATABASE", "products" }
};

var containerBuilder = new ContainerBuilder()
    .WithEnvironment(env);

Pre-configured Containers

Testcontainers offers a range of pre-configured containers that simplify the use of various services. This eliminates the need for specifying the image or port mapping. A MySqlContainer provides the option to directly obtain the appropriate connection string for the database.

var container = new MySqlBuilder().Build();
var connectionString = container.GetConnectionString();

Test Fixtures

To centralize the configuration of Testcontainers and not generate and start a new container for each test method call, one can use the capabilities of various testing frameworks to create so-called test fixtures.

For xUnit, you can create a class called DatabaseFixture that handles initializing and starting the container. A class that implements the ICollectionFixture<DatabaseFixture> interface can then control which tests share a container instance.

public class DatabaseFixture : IAsyncLifetime
{
    public async Task InitializeAsync()
    {
        _container = BuildContainer();
        await _container.StartAsync();
    }

    public async Task DisposeAsync()
    {
        await _container.StopAsync();
    }

    // ...
}

[CollectionDefinition(Name)]
public class DatabaseCollectionFixture : ICollectionFixture<DatabaseFixture>
{
    public const string Name = "DatabaseCollection";
}

The corresponding tests are annotated with the Collection attribute and the collection name that provides the fixture container.

[Collection(DatabaseCollectionFixture.Name)]
public class DatabaseTests
{
    // ...
}

The project in the GitHub repository for this post shows a complete example: https://github.com/davull/demo-docker-testcontainers

CI/CD Pipeline

Testcontainers can be run in a CI/CD pipeline in the same way as locally. The only requirement is that Docker and the Docker CLI are also installed on the build server. In the case of Azure DevOps, this is already the case for Microsoft-hosted build agents. If you are using your own build containers, the Docker CLI can be installed via the task DockerInstaller@0.

- task: DockerInstaller@0
  displayName: "Install docker cli"
  inputs:
    dockerVersion: 26.1.0

When executing the test step, the required images are downloaded, if not already present, and the containers are started.

Azure DevOps Output