💬 0

Dica Rápida - TestContainers

Olá!

Esta é mais uma Dica Rápida e desta vez vamos falar sobre TestContainers, uma ferramenta baseada em Docker para facilitar testes de integração.

Vamos lá!

O Problema

Antes de falar sobre a ferramenta é interessante falar sobre qual problema ela resolve. E o TestContainers vem para resolver um problema muito comum: a necessidade deploy em ambientes remotos para a execução de testes de integração por conta de dependências de infraestrutura como, por exemplo, bancos de dados e serviços de mensageria.

Ao mesmo tempo, testes de integração, quando não automatizados, não são executados pelas esteiras de CI/CD, o que acaba por enfraquecer os testes, reduzindo sua confiabilidade.

A Solução

TestContainers é uma biblioteca que permite ao desenvolvedor criar containers com as mais variadas dependências e utilizá-las em seus testes automatizados, tornando o ambiente local suficiente para a realização de testes de integração e, ao mesmo tempo, para permitir à esteira de deploy executar os testes para viabilizar um deploy mais confiável.

Requisitos

Para utilizar o TestContainers basta ter instalado o Docker localmente, a biblioteca faz toda a mágica acontecer a partir daí.

Nosso Projeto

Para o projeto de demonstração utilizo uma versão da biblioteca com Postgres para fazer testes de uma API via xUnit.

Vamos ver como fazer, você vai se surpreender com a simplicidade.

A Aplicação

Por se tratar de uma demonstração a aplicação é bastante simples, trata-se de um gerenciador de tarefas via Web API. Nele é possível apenas criar e finalizar uma tarefa, sem qualquer funcionalidade adicional.

Como esta é uma Dica Rápida vou me furtar de escrever o código da aplicação por aqui, você vai poder conferí-la no Github ao final do artigo.

Os Testes

Aqui vale a pena explicar um pouco mais.

Para viabilizar os testes vamos lançar mão do WebApplicationFactory, uma classe que executa localmente nossa aplicação, no caso uma Web API do AspNet, confira o código abaixo:

public class TodoWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder("postgres:16-alpine")
        .WithDatabase("test_db")
        .WithUsername("postgres")
        .WithPassword("postgres")
        .Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            var descriptor = services.SingleOrDefault(s => s.ServiceType == typeof(DbContextOptions<TodoDbContext>));
            services.Remove(descriptor!);
            
            services.AddDbContext<TodoDbContext>(options =>
                options.UseNpgsql(_dbContainer.GetConnectionString()));
        });
    }
    
    public async ValueTask InitializeAsync()
    {
        await _dbContainer.StartAsync();
        
        using var scope = Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
        await db.Database.EnsureCreatedAsync();
    }
    
    public new async Task DisposeAsync() =>
        await _dbContainer.StopAsync();
}

Vamos avaliar o código acima para entender como funciona.

Repare na propriedade privada PostgreSqlContainer, ela cria uma instância do container Docker do Postgres via PostgreSqlBuilder. Esse builder recebe a versão da imagem do Postgres que será utilizada pela biblioteca (postgres:16-alpine) e cria o banco de dados test_db em seguinda. Além disso informa as credenciais que devem ser utilizadas para a conexão com o banco de dados.

Acredite: é só esse o necessário para executar o container!

No método seguinte ConfigureWebHost é onde informamos à nossa Web API que ela deve se referir ao banco de dados do container para ser executada. Primeiro removemos o DbContext configurado na aplicação e, em seguida, registramos uma versão deste configurado para utilizar o container via _dbContainer.GetConnectionString().

Por conta do uso da interface IAsyncLifetime podemos adicionar um método de inicialização que vai criar a tabela onde abrigaremos nossas tarefas.

Isso é o suficiente para avançarmos ao próximo passo, a implementação dos testes de fato.

Aqui temos o código de um dos endpoints da nossa Web API, a responsável por criar uma tarefa:

public class TodoTest(TodoWebApplicationFactory factory) 
    : IClassFixture<TodoWebApplicationFactory>, IDisposable, IAsyncDisposable
{    
    [Fact]
    public async Task ShouldCreateTodo()
    {
        //Arrange
        const string todoTitle = "Todo 1";
        var client = factory.CreateClient();
        
        //Act
        var result = await client.PostAsJsonAsync("/todo/create",  
            new CreateTodoCommand(todoTitle), CancellationToken.None);

        //Assert
        Assert.True(result.IsSuccessStatusCode);
        var scope = factory.Server.Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
        var todo = context.Todos.FirstOrDefault(t => t.Title == todoTitle);
        Assert.NotNull(todo);
    }

Repare que para tornar nossa aplicação customizada para testes executável precisamos injetar nossa TodoWebApplicationFactory em nossa classe de testes. Ela é responsável por criar o cliente HTTP que acessará os endpoints disponíveis.

No teste o que temos é bastante simples: a factory permite a criação do cliente HTTP, que é configurado para enviar um POST para nosso endpoint de criação de tarefas, que é acionado em seguida.

O interessante acontece logo depois: obtemos, a partir de um escopo, a instância do nosso TodoDbContext e, a partir dele, verificamos se nossa tarefa foi inserida com sucesso no banco de dados.

Com isso temos um teste de integração completo, com acesso HTTP e ao Postgres verificáveis.

Vantagens

Você deve ter percebido a essa altura algumas outras vantagens no uso do TestContainers em relação a métodos convencionais:

  • Não é necessário utilizar um banco em memória que emule o comportamento do banco utilizado em produção;
  • Não há mocks! e;
  • Há um aumento brutal de produtividade.

O primeiro ponto me parece fundamental porque via banco de dados em memória, ou outras técnicas como o uso de coleções também em memória, não é possível explorar o verdadeiro comportamento de sua base de dados. Se houver a necessidade de testar uma query SQL o banco em memória vai apresentar limitações e, ainda que seja utilizado um banco como SQLite, algo também bastante comum, haverá diferenças entre as capacidades dele e as de um banco como Postgres (SQLite, por exemplo, não tem a disposição todos os tipos de dados que o Postgres oferece).

O segundo ponto é interessante por dois motivos: mocks não permitem testar os mapeamentos entre campos do seu modelo e as colunas do banco de dados. É claro que o EF Core facilita esse mapeamento na medida em que cria a tabela no banco a partir do modelo com a estratégia Code First mas, no caso de uma aplicação que utilize Dapper ou até mesmo ADO.NET puro, esse trabalho é basicamente impossível.

O terceiro me parece a principal justificativa para o uso do TestContainers, haja visto que o tempo utilizado para testes em ambientes remotos não costuma ser pouco, principalmente quando existe a necessidade de passar pela esteira de deploy para que a aplicação se faça disponível em um desses ambientes, o que pode ocasionar interrupções do processo caso ela esteja fora do ar ou até mesmo caso as dependências de infraestrutura estejam indisponíveis no momento dos testes.

Conclusão

Este modelo de testes automatizados com TestContainers é bastante poderoso por permitir que os testes automatizados estejam o mais próximos possível do ambiente de produção, tornando seus testes mais confiáveis e passíveis de execução em qualquer ambiente onde o Docker esteja disponível.

Algo que não é demonstrado neste post, afinal é uma Dica Rápida, é que é possível criar imagens das suas aplicações a partir de um docker file. Ou seja, é totalmente viável ter uma comunicação entre duas aplicações via HTTP ou mesmo mensageria, tornando seus testes de integração ainda mais poderosos.

Entendo o TestContainers como uma ferramenta indispensável para o desenvolvimento de aplicações dada sua simplicidade e as principais vantagens que elencamos acima.

Como sempre o código de exemplo do artigo está disponível no Github, esteja à vontade para baixar e testar.

Gostou? Me deixe saber pelo indicadores, comentários ou mesmo pelas minhas redes sociais.

Nos vemos no próximo post!

Comentários (0)

Deixe seu comentário

Nenhum comentário ainda. Seja o primeiro a comentar!