Testes de integração melhores usando containers Docker e a biblioteca Testcontainers

Costuma ser complicado construir uma infra de testes de integração fácil de usar e que simplifique a vida do desenvolvedor no dia a dia, ao mesmo tempo em que promove testes confiáveis e rápidos.

A biblioteca testconainers vem para nos ajudar nessa frente. Neste artigo, apresento a biblioteca e mostro algumas formas como pode ser usada para construir uma boa infra de testes de integração para .NET.

Vale notar que o projeto Testcontainers abarca várias linguagens e stacks, incluindo Java, DotNet, Python, Go, Node e Ruby.

Criando uma imagem Docker em C#

Com a biblioteca testcontainers-dotnet, podemos facilmente construir uma nova imagem Docker a partir de código C#. A imagem abaixo mostra a flexibilidade da biblioteca:

Métodos de customização de imagem encontrados na classe TestcontainersBuilder, como WithImage, WithCommand e WithEnvironment.
Figura 1. Métodos de customização de imagem encontrados na classe TestcontainersBuilder

A título de exemplo, podemos construir uma imagem de banco de dados PostgreSQL para usar em testes de integração:

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();

Como podemos ver, temos bastante controle sobre a forma como a imagem será utilizada. Para provar que a biblioteca consegue de fato providenciar um container conforme configurado acima, podemos escrever um teste simples que executa um comando SELECT 1 no banco:

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 A interface do xUnit IAsyncLifetime intercepta o ciclo de vida de uma classe de testes. No nosso caso, quando a classe é criada e quando é destruída.
2 No método InitializeAsync podemos inicializar nosso container.
3 No método DisposeAsync podemos descartar nosso container, que não será mais necessário.
4 Neste teste, abrimos uma conexão com o banco criado, e executamos um select simples.

Como vimos, é bem fácil configurar uma imagem. Mas a biblioteca não apenas provê meios para construir containers customizados: ela também disponibiliza builders pré-configurados chamados Módulos, como veremos a seguir.

Utilizando um módulo de banco de dados

Além do builder básico, a biblioteca conta com módulos especializados para construção de containers de banco de dados e de brokers como o RabbitMQ. Esses módulos podem ser utilizados através dos métodos de extensão WithDatabase e WithBroker.

Podemos remodelar o exemplo anterior, configurando o Postgres com o módulo de banco de dados:

//...
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 A configuração do container fica muito mais simples com o módulo, que já traz uma configuração padrão bem melhor do que aquela que usamos anteriormente
2 Também não precisamos mais nos preocupar com a string de conexão, que agora é gerada pela própria biblioteca. Além disso, esse módulo implementa rotação de portas, o que significa que não precisamos nos preocupar múltiplos bancos falhando em subir devido a conflito de portas.

Infra para testes de integração

Qualquer aplicação real terá vários cenários de teste para implementar, e uma infraestrutura bem construída pode facilitar muito o desenvolvimento desses testes.

Nessa seção, veremos como utilizar a biblioteca testcontainers para construir essa infraestrutura.

É muito provável que uma aplicação ou serviço se encaixe num dos dois cenários abaixo:

  1. API REST ou gRPC

  2. Worker realizando processamento de filas, comum em arquiteturas do tipo EDA (Event Driven Architecture)

Para o cenário 1, podemos utilizar a classe WebApplicationFactory, que vai facilitar o teste dos endpoints da nossa API, e fornecer pontos de configuração específicos para os testes.

É muito comum que aplicações que se encaixam no cenário 2 disponibilizem também um endpoint de healthcheck, especialmente em serviços criados para rodar como um pod no Kubernetes. Nesses casos, podemos fazer o mesmo, utilizando o WebApplicationFactory. Em outros cenários, bastaria criar uma classe base com essa infra do zero, e utilizar um IClassFixture<ClasseBaseTeste> ou ICollectionFixture<ClasseBaseTeste>, como veremos mais adiante.

No exemplo adiante, vamos investigar como montar uma infra que tira proveito da biblioteca testcontainers.

Exemplo: CustomerApiFactory

No exemplo abaixo, a classe CustomerApiFactory estende WebApplicationFactory e implementa a interface IAsyncLifetime do xUnit.

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);
        }
    }
}

No trecho acima, é interessante analisarmos alguns pontos importantes:

1 Essa ApiFactory estende WebApplicationFactory<IApiMarker>, utilizando uma interface vazia (IApiMarker) como marcador de assembly do projeto dotnet da API. Essa é uma prática comum para sinalizar o assembly onde se encontra um recurso desejado, normalmente utilizada em ferramentas que utilizam reflexão.
2 Como os testes rodarão em ambiente local, não queremos que utilizar a implementação real de conexão com o banco, então podemos remover a implementação original.
3 Em seguida, registramos uma implementação fake do IDbConnectionFactory, que utiliza a string de conexão fornecida pelo _dbContainer.
4 Sempre que utilizarmos um banco de dados SQL, existe a preocupação de executar as migrações do banco, para que o modelo de dados esteja devidamente atualizado. Como os containers são descartáveis, logo após inicializá-los temos a oportunidade de executar as migrações de banco.

Com isso, podemos escrever alguns testes de integração, executando nossa API, usando containers de banco de dados criados dinamicamente via testcontainers:

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");
    }
}

Compartilhando instâncias entre testes

No exemplo acima, a classe de teste está estendendo a classe base CustomerApiFactory. Isso implica que o xUnit vai tratá-la como uma classe de testes qualquer, criando uma nova instância da classe para cada teste. Isso implica que cada teste terá seu próprio container de banco de dados.

Esse comportamento é bom para garantir o isolamento e a reproducibilidade dos testes, mas pode acarretar lentidão na sua execução.

Se isso for um problema, podemos compartilhar uma instância da CustomerApiFactory entre diferentes testes usando uma dessas duas interfaces do xUnit:

  • IClassFixture<CustomerApi>

  • ICollectionFixture<CustomerApi>

Ao implementar IClassFixture<CustomerApi> numa classe de testes, uma instância de CustomerApi será compartilhada entre todos os testes desta classe.

É importante notar que ao implementar IClassFixture<TFixture>, conforme a documentação também é necessário implementar a interface IAsyncLifeTime para configurar comportamento assíncrono, usado na inicialização dos containers.

Exemplo de uma classe que compartilha estado entre seus testes
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();
    }
}

Já para compartilhar uma instância de CustomerApi entre os testes de uma coleção, podemos fazer uso da interface ICollectionFixture<CustomerApi>.

Assim como no caso da interface IClassFixture<TFixture>, também é necessário implementar a interface IAsyncLifeTime.

Exemplo de uma classe que compartilha estado entre os testes de uma coleção chamada CustomerAPI
[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();
    }
}

Considerações finais

A biblioteca testcontainers pode facilitar muito o setup de testes de integração de microserviços e de aplicações .NET em geral.

Com uma infra bem estruturada, rodar testes de integração pode ser tão simples quanto executar o comando dotnet run.

Ao compartilhar containers entre testes, é sempre bom avaliar com cuidado dois fatores que exigem balanceamento: tempo de execução e determinismo.

Uma quantidade menor de containers implica testes mais rápidos, mas sempre a custa do determinismo. Isso ocorre pois a interação de um teste com a base de dados pode "sujar" a execução de um teste posterior, causando falsos negativos ou falsos positivos. Lembre-se: determinismo é um fator crucial para garantir a qualidade de uma suíte de testes.

Os exemplos apresentados nesse artigo podem ser encontrados neste repositório do GitHub, incluindo a API que foi testada. Cenários reais vão exigir muitos outros pequenos ajustes, mas espero que estes exemplos ajudem a guiar futuras implementações.

Por fim, gostaria de recomendar o curso From Zero to Hero: Integration testing in ASP.NET Core, do Nick Chapsas, que apresenta estes tópicos e outros de forma super objetiva.

Outras opções para compartilhar:
comments powered by Disqus