Software Engineering5 min read

Automating integration tests with TestContainers and Docker

How to use TestContainers, Docker, xUnit, and WebApplicationFactory to automate integration tests with real dependencies in .NET applications.

Automating integration tests with TestContainers and Docker

Software Engineering

dotnet, testing, docker, testcontainers

en

Setting up an environment for integration tests is not always simple. Many resources are external to the application, such as the database, which often requires creating a dedicated test environment or mocking those services.

To solve this problem, we can create Docker containers to provide those services whenever the test runs. A simple way to do that is by using TestContainers, an open source library that makes it straightforward to create Docker containers at runtime for the application’s external dependencies.

For our example, we will use the following technologies:

  • xUnit
  • Docker
  • TestContainers

What is TestContainers?

TestContainers is a library that allows Docker containers to run during software tests. It supports several programming languages, including .NET.

This tool is especially useful for integration tests, where you need to test the interaction between different components of a system. Instead of depending on external environments, TestContainers lets you start containers, such as databases, messaging systems, or web services, directly from the test code.

Some advantages of using TestContainers include:

  1. Test isolation: containers are started only while tests are running, ensuring an isolated and consistent environment for each execution.
  2. Reproducibility: by using containers, you ensure that the test environment is reproducible regardless of the execution environment.
  3. Easy configuration: configuring and managing test environments can be complex, but TestContainers simplifies this process by allowing you to define and configure containers in the test code.

Below is an example of creating a PostgreSQL container.

private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
    .WithImage("postgres:latest")
    .WithDatabase("TestDB")
    .WithUsername("postgres")
    .WithPassword("postgres")
    .Build();

You can use the PostgreSqlContainer instance to access information about the container created for the service. With this, we no longer need to create mocks when running operations that involve the database.

Configuring WebApplicationFactory

WebApplicationFactory is part of the Microsoft.AspNetCore.Mvc.Testing namespace and provides a simple way to start a web server in a test environment. It also lets us override some configuration, such as the connection string.

We will create the IntegrationTestWebAppFactory class, which will be responsible for:

  • Creating and configuring a PostgreSqlContainer instance;
  • Overriding the connection string information through ConfigureTestServices;
  • Managing the container lifecycle with the IAsyncLifetime interface.

Note: Code first applications should run migrations before tests execute, and this can be configured to happen during application startup. Database first applications can have their scripts executed with support from the TestServer class provided by WebApplicationFactory.

Below is our IntegrationTestWebAppFactory class:

public sealed class IntegrationTestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private NpgsqlConnection _dbConnection = default!;
    private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
        .WithImage("postgres:latest")
        .WithPortBinding(5432)
        .WithExposedPort(5432)
        .WithDatabase("MarsDB")
        .WithUsername("postgres")
        .WithPassword("postgres")
        .Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            var descriptor =
                services.SingleOrDefault(s => s.ServiceType == typeof(DbContextOptions<ApplicationContext>));

            if (descriptor is not null)
                services.Remove(descriptor);

            services.AddDbContext<ApplicationContext>(option =>
            {
                option
                    .UseNpgsql(_dbContainer.GetConnectionString());
            });
        });
    }

    public async Task InitializeAsync()
    {
        await _dbContainer.StartAsync();
    }

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

Finally, we will create the WebApplicationCollectionFixture class, which implements the ICollectionFixture<TWebApplicationFactory> interface. This class will be referenced by test classes that share object instances defined in WebApplicationCollectionFixture, avoiding the need to recreate those objects for each test.

[CollectionDefinition("WebApplicationCollectionFixture")]
public class WebApplicationCollectionFixture : ICollectionFixture<IntegrationTestWebAppFactory>
{}

Implementing the test

First, we will create the BaseIntegrationTest class, which will be responsible for abstracting and sharing some object instances between test cases.

public abstract class BaseIntegrationTest
{
    protected readonly HttpClient Client;
    protected readonly ISender Sender;
    protected readonly ApplicationContext Context;

    protected BaseIntegrationTest(IntegrationTestWebAppFactory factory)
    {
        Client = factory.CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });
        Sender = scope.ServiceProvider.GetRequiredService<ISender>();
        Context = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
    }
}

The base class has only an HTTP client for making API calls, the ISender interface, which is responsible for sending Commands and Queries, and our database context, using Entity Framework in this example.

Now we will write the test case, which should register a user account when the input data is valid.

[Collection("WebApplicationCollectionFixture")]
public class AccountTests : BaseIntegrationTest
{
    public AccountTests(IntegrationTestWebAppFactory factory) : base(factory)
    {
    }

    [Fact]
    public async Task RegisterUser_ShouldCreateAnUser_WhenInputIsValid()
    {
        // Arrange
        var input = new
        {
            Email = "johndoe@mail.com",
            Password = "12354"
        };

        // Action
        var result = await Client.PostAsJsonAsync("/api/Account/register", input);

        // Asserts
        result.EnsureSuccessStatusCode();
        var userCreated = await Context.Users
            .SingleAsync(x => x.Email == Email.Create("johndoe@mail.com"));

        var person = await Context.Accounts.SingleAsync(x => x.Id == userCreated.AccountId);

        Assert.True(userCreated.Password.IsMatchedPassword("12354"));
    }
}

With that done, we can run the test and see the full automation in practice.

Integrating and delivering

To finish, let’s create a GitHub Actions pipeline to integrate our test into the CI/CD flow. Below is a model for running the tests inside a Pull Request.

name: Integration Tests Pipeline - Pull Request

on:
  pull_request:
    types:
      - opened
      - synchronize
    branches:
      - "**"

jobs:
  integration-tests:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v3
      - name: Setup .NET
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: "8.x"
      - name: Restore dependencies
        run: dotnet restore
      - name: Build
        run: dotnet build --no-restore
      - name: Integration Test
        working-directory: ./MyApplication.IntegrationTests
        run: dotnet test --no-build --verbosity normal

Conclusion

Automating integration tests with TestContainers and Docker provides a robust and efficient way to handle external dependencies during development and test execution.

Key benefits include test isolation, ensuring containers are started only while tests run and providing a consistent environment for each execution. Reproducibility is also guaranteed regardless of the execution environment, which improves confidence in the test results.

#dotnet#testing#docker#testcontainers
Francisco Junior

Hi, I am Francisco Junior.

I am a Software Engineer and Tech Lead.

I work daily building distributed financial platforms. On this blog I share lessons about architecture, engineering, cloud, platform, technical leadership, and real decisions from software development.