Dyego Maas - Blog

Generative AI Consultant and Software Architect

Better integration tests with Docker containers and the Testcontainers library

Better integration tests with Docker containers and the Testcontainers library

In this article I show how to use the testcontainers library to set up your tests' external dependencies, such as databases and message brokers, in a simple and reliable way

9 min read

Building integration test infrastructure that is easy to use and makes developers’ day-to-day life simpler, while also keeping tests reliable and fast, tends to be tricky.

The testcontainers library is here to help with that. In this article, I introduce the library and show a few ways to use it to build solid integration test infrastructure for .NET.

Nota

It’s worth noting that the Testcontainers project spans several languages and stacks, including Java, DotNet, Python, Go, Node and Ruby.

Creating a Docker image in C#

With the testcontainers-dotnet library, we can easily build a new Docker image from C# code. The image below shows how flexible the library is:

Image customization methods found in the TestcontainersBuilder class, such as WithImage, WithCommand and WithEnvironment.
Image customization methods found in the TestcontainersBuilder class

As an example, we can build a PostgreSQL database image to use in integration tests:

Configuring a PostgreSQL container
private readonly TestcontainersContainer _dbContainer = 
  new TestcontainersBuilder<TestcontainersContainer>()
      .WithImage("postgres:11") 
      .WithName("my-postgres")
      .WithEnvironment("POSTGRES_DB", "testdatabase")
      .WithEnvironment("PGDATA", "/data/postgres")
      .WithEnvironment("POSTGRES_USERNAME", "postgres")
      .WithEnvironment("POSTGRES_PASSWORD", "postgres")
      .WithPortBinding(5432, 5432)
      .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432))
      .Build();

As we can see, we have a lot of control over how the image will be used. To prove that the library can actually provide a container configured as above, we can write a simple test that runs a SELECT 1 command against the database:

Complete test using Testcontainers
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using FluentAssertions;
using Npgsql;
using Xunit;

namespace Examples;

public class TestcontainersTests : IAsyncLifetime // <1>
{
  private readonly TestcontainersContainer _dbContainer = 
      new TestcontainersBuilder<TestcontainersContainer>()
          .WithImage("postgres:11")
          .WithName("my-postgres")
          .WithEnvironment("POSTGRES_DB", "testdatabase")
          .WithEnvironment("PGDATA", "/data/postgres")
          .WithEnvironment("POSTGRES_USERNAME", "customUser")
          .WithEnvironment("POSTGRES_PASSWORD", "customPassword")
          .WithPortBinding(5555, 5432)
          .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432))
          .Build();

  [Fact]
  public async Task Should_Select1_FromDatabase() // <4>
  {
      await using var connection = new NpgsqlConnection(
          "Host=localhost:5555;Username=customUser;Password=customPassword;Database=testdatabase"
      );
      await connection.OpenAsync();

      var command = new NpgsqlCommand("SELECT 1", connection);
      var result = (int?)await command.ExecuteScalarAsync();

      result.Should().Be(1);
  }

  public async Task InitializeAsync()
  {
      await _dbContainer.StartAsync(); // <2>
  }
  
  public async Task DisposeAsync()
  {
      await _dbContainer.DisposeAsync(); // <3>
  }
}
  1. xUnit’s IAsyncLifetime interface hooks into the lifecycle of a test class. In our case, when the class is created and when it is torn down.
  2. In the InitializeAsync method we can start our container.
  3. In the DisposeAsync method we can dispose of our container, which we no longer need.
  4. In this test, we open a connection to the database we created and run a simple select.

As we’ve seen, configuring an image is pretty easy. But the library does more than give us ways to build custom containers: it also ships pre-configured builders called Modules, as we’ll see next.

Using a database module

Besides the basic builder, the library has specialized modules for building database containers and brokers such as RabbitMQ. These modules are available through the WithDatabase and WithBroker extension methods.

We can rework the previous example, configuring Postgres with the database module:

Using a database module
//...
private readonly TestcontainerDatabase _dbContainer = // <1>
  new TestcontainersBuilder<PostgreSqlTestcontainer>()
      .WithDatabase(new PostgreSqlTestcontainerConfiguration
      {
          Database = "testdatabase",
          Username = "customUser",
          Password = "customPassword"
      })
      .Build();

[Fact]
public async Task Should_Select1_FromDatabase()
{
  await using var connection = new NpgsqlConnection(_dbContainer.ConnectionString); // <2>
  await connection.OpenAsync();

  var command = new NpgsqlCommand("SELECT 1", connection);
  var result = (int?)await command.ExecuteScalarAsync();

  result.Should().Be(1);
}
//...
  1. Configuring the container gets much simpler with the module, which comes with a far better default configuration than the one we used before
  2. We also no longer have to worry about the connection string, which is now generated by the library itself. On top of that, this module implements port rotation, which means we don’t have to worry about multiple databases failing to start because of port conflicts.

Integration test infrastructure

Any real application will have plenty of test scenarios to implement, and well-built infrastructure can make writing those tests much easier.

In this section, we’ll see how to use the testcontainers library to build that infrastructure.

Most applications or services will fit one of these two scenarios:

  1. REST or gRPC API
  2. A worker processing queues, common in EDA (Event Driven Architecture) systems

For scenario 1, we can use the WebApplicationFactory class, which makes it easy to test our API’s endpoints and gives us test-specific configuration hooks.

It’s very common for applications in scenario 2 to also expose a healthcheck endpoint, especially services built to run as a pod in Kubernetes. In those cases, we can do the same thing and use WebApplicationFactory. In other scenarios, it’s enough to build a base class with this infrastructure from scratch and use an IClassFixture<BaseTestClass> or ICollectionFixture<BaseTestClass>, as we’ll see later on.

In the next example, we’ll look at how to put together infrastructure that takes advantage of the testcontainers library.

Example: CustomerApiFactory

In the example below, the CustomerApiFactory class extends WebApplicationFactory and implements xUnit’s IAsyncLifetime interface.

CustomerApiFactory - Test Infrastructure
public class CustomerApiFactory : WebApplicationFactory<IApiMarker>, IAsyncLifetime // <1>
{
  private readonly TestcontainerDatabase _dbContainer = 
      new TestcontainersBuilder<PostgreSqlTestcontainer>()
          .WithDatabase(new PostgreSqlTestcontainerConfiguration
          {
              Database = "testdatabase",
              Username = "customUser",
              Password = "customPassword"
          })
          .Build();
  
  protected override void ConfigureWebHost(IWebHostBuilder builder)
  {
      builder.ConfigureLogging(logging =>
      {
          logging.ClearProviders();
      });

      builder.ConfigureServices(services =>
      {            
          services.RemoveAll(typeof(IDbConnectionFactory)); // <2>
          services.TryAddSingleton<IDbConnectionFactory>(_ => // <3>
              new NpgsqlConnectionFactory(_dbContainer.ConnectionString) 
          ); 
      });
      
      base.ConfigureWebHost(builder);
  }

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

      var scope = Services.CreateScope();
      var databaseContext = scope.ServiceProvider.GetService<DatabaseContext>();
      await databaseContext.Database.MigrateAsync(); // <4>
  }
  
  public async Task DisposeAsync()
  {
      await _dbContainer.DisposeAsync();
  }
  
  private class NpgsqlConnectionFactory : IDbConnectionFactory
  {
      private readonly string _connectionString;

      public NpgsqlConnectionFactory(string connectionString)
      {
          _connectionString = connectionString;
      }

      public DbConnection CreateConnection(string nameOrConnectionString)
      {
          return new NpgsqlConnection(_connectionString);
      }
  }
}

A few points in the snippet above are worth a closer look:

  1. This ApiFactory extends WebApplicationFactory<IApiMarker>, using an empty interface (IApiMarker) as an assembly marker for the API’s dotnet project. This is a common practice to point to the assembly where a given resource lives, usually in tools that rely on reflection.
  2. Since the tests will run locally, we don’t want to use the real database connection implementation, so we can remove the original one.
  3. Next, we register a fake implementation of IDbConnectionFactory that uses the connection string provided by _dbContainer.
  4. Whenever we use a SQL database, we need to run the database migrations so the data model is up to date. Since the containers are disposable, right after starting them we get the chance to run the migrations.

With that, we can write some integration tests that run our API against database containers created on the fly by testcontainers:

Integration tests using the infrastructure
using System.Net;
using ExampleTests._03_ExampleTestInfrastructure.Infra;
using FluentAssertions;
using WebAapi.Controllers;
using WebAapi.Entities;

namespace ExampleTests._03_ExampleTestInfrastructure;

public class GetCustomerTests : CustomerApiFactory
{
  private readonly HttpClient _httpClient;

  public GetCustomerTests()
  {
      _httpClient = CreateDefaultClient();
  }

  [Fact]
  public async Task GetCustomers_ShouldReturnEmptyList_WhenNoCustomersExist()
  {
      var customers = await _httpClient.GetAsync<List<Customer>>("/Customers");
      
      customers.Should().BeEmpty();
  }
  
  [Fact]
  public async Task Get_ShouldReturnNoFound_WhenNoCustomersExist()
  {
      var response = await _httpClient.GetAsync("/Customers/1");

      response.StatusCode.Should().Be(HttpStatusCode.NotFound);
  }
  
  [Fact]
  public async Task Post_ShouldInsert_ValidCustomer()
  {
      var response = await _httpClient.PostAsync<Customer, NewId>("/Customers", new Customer
      {
          Name = "Test Customer"
      });
      
      response.Id.Should().BeGreaterThan(0);
  }
      
  [Fact]
  public async Task GetExistingUser_ShouldReturn_TheUser()
  {
      var postResponse = await _httpClient.PostAsync<Customer, NewId>("/Customers", new Customer
      {
          Name = "Test Customer"
      });
      
      var customer = await _httpClient.GetAsync<Customer>($"/Customers/{postResponse.Id}");

      customer.Name.Should().Be("Test Customer");
  }
}

Sharing instances across tests

In the example above, the test class extends the CustomerApiFactory base class. That means xUnit will treat it like any other test class, creating a new instance of the class for each test. In other words, each test gets its own database container.

That behavior is good for test isolation and reproducibility, but it can slow the test run down.

If that becomes a problem, we can share a CustomerApiFactory instance across tests using one of these two xUnit interfaces:

  • IClassFixture<CustomerApi>
  • ICollectionFixture<CustomerApi>

When a test class implements IClassFixture<CustomerApi>, a single CustomerApi instance is shared across all the tests in that class.

Importante

Note that when implementing IClassFixture<TFixture>, the documentation says you also need to implement the IAsyncLifeTime interface to set up the asynchronous behavior used to start the containers.

Example of a class that shares state across its tests

Sharing state across the class's tests
public class GetCustomerTests : IClassFixture<CustomerApiFactory>, IAsyncLifetime
{
  private readonly CustomerApiFactory _applicationFactory;
  private readonly HttpClient _httpClient;

  public GetCustomerTests(CustomerApiFactory applicationFactory)
  {
      _applicationFactory = applicationFactory;
      _httpClient = applicationFactory.CreateDefaultClient();
  }

  //...

  public Task InitializeAsync()
  {
      return _applicationFactory.InitializeAsync();
  }

  public Task DisposeAsync()
  {
      return _applicationFactory.DisposeAsync();
  }
}

To share a CustomerApi instance across the tests in a collection, we can use the ICollectionFixture<CustomerApi> interface instead.

Importante

Just like with the IClassFixture<TFixture> interface, you also need to implement the IAsyncLifeTime interface.

Example of a class that shares state across the tests of a collection named CustomerAPI

Sharing state across a test collection
[Collection("CustomerAPI")]
public class GetCustomerTests : IClassFixture<CustomerApiFactory>, IAsyncLifetime
{
  private readonly CustomerApiFactory _applicationFactory;
  private readonly HttpClient _httpClient;

  public GetCustomerTests(CustomerApiFactory applicationFactory)
  {
      _applicationFactory = applicationFactory;
      _httpClient = applicationFactory.CreateDefaultClient();
  }

  //...

  public Task InitializeAsync()
  {
      return _applicationFactory.InitializeAsync();
  }

  public Task DisposeAsync()
  {
      return _applicationFactory.DisposeAsync();
  }
}

Final thoughts

The testcontainers library can make setting up integration tests for microservices, and for .NET applications in general, a lot easier.

With well-structured infrastructure, running integration tests can be as simple as running dotnet run.

When sharing containers across tests, it’s always worth carefully weighing two factors that pull against each other: execution time and determinism.

Nota

Fewer containers means faster tests, but always at the expense of determinism. That’s because one test’s interaction with the database can “dirty” the run of a later test, causing false negatives or false positives. Remember: determinism is crucial to the quality of a test suite.

The examples in this article are available in this GitHub repository, including the API under test. Real-world scenarios will call for plenty of other small tweaks, but I hope these examples help guide future implementations.

Finally, I’d like to recommend the course From Zero to Hero: Integration testing in ASP.NET Core, by Nick Chapsas, which covers these topics and more in a very to-the-point way.