Automatizando Testes de Integração com TestContainers e Docker

Francisco Junior
TDD .Net

Montar um ambiente para testes integrados nem sempre é tão simples, muitos dos recursos são externos a aplicação, como o banco de dados, onde se faz necessária a criação de um novo ambiente exclusivo para os testes ou o mock desses serviços. Para resolver esse problema podemos criar containers Docker para prover esses serviços sempre que o teste for executado. Uma forma bem simples de fazer isso é com a utilização do TestContainers, uma biblioteca open source que viabiliza de forma simples e direta a construção de containers Docker em tempo de execução para as dependências externas da aplicação.

Para nosso exemplo iremos utilizar as seguintes tecnologias:

  • xUnit
  • Docker
  • TestContainers

O que é TestContainers?

TestContainers é uma biblioteca que permite a execução de contêineres Docker durante os testes de software. Ela oferece suporte a uma variedade de linguagens de programação, dentre elas o .Net.

Essa ferramenta é especialmente útil para testes de integração, onde é necessário testar a interação entre diferentes componentes de um sistema. Em vez de depender de ambientes externos, o TestContainers permite que você inicie contêineres (por exemplo, bancos de dados, mensageria ou serviços da web) a partir do código de teste.

Algumas das vantagens do uso do TestContainers incluem:

  1. Isolamento de Testes: Os contêineres são iniciados apenas durante a execução dos testes, garantindo um ambiente isolado e consistente para cada execução.
  2. Reprodutibilidade: Ao utilizar contêineres, você garante que o ambiente de teste seja reproduzível, independentemente do ambiente de execução.
  3. Facilidade de Configuração: Configurar e gerenciar ambientes de teste pode ser complexo, mas o TestContainers simplifica esse processo, permitindo que você defina e configure seus contêineres no código de teste.

Abaixo temos o exemplo da criação de um container PostgreSQL.

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

Você pode utilizar a instância do PostgreSqlContainer para acessar as informações do container que foi criado para o serviço. Com isso não precisamos mais realizar mocks ao executar operações que envolvem o banco de dados.

Configurando o WebApplicationFactory

WebApplicationFactory faz parte do namespace Microsoft.AspNetCore.Mvc.Testing que permite de forma simples iniciarmos um servidor web em ambiente de teste. Ele permite também sobrescrevermos algumas configurações, como a string de conexão por exemplo.

Iremos criar a classe IntegrationTestWebAppFactory, que vai ser responsável por:

  • Criar e configurar uma instância do PostgreSqlContainer;
  • Sobrescrever as informações da conection string através do ConfigureTestServices;
  • Gerenciar o ciclo de vida do contêiner com a interface IAsyncLifetime;

💡 Nota: Aplicações code first devem executar as migrations antes da execução dos testes, podendo ser configurado para serem executados no startup da aplicação. Já aplicações database first podem ter seus scripts executados com o apoio da classe TestServer fornecida pelo WebApplicationFactory.

Abaixo teremos nossa classe IntegrationTestWebAppFactory:

public sealed class IntegrationTestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private NpgsqlConnection _dbConnection = default!;
    private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
        .WithImage("postgres:latest")
        .WithDatabase("TestDB")
        .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();
    }
}

Para finalizar iremos criar a classe WebApplicationCollectionFixture que implementa a interface ICollectionFixture<TWebApplicationFactory>. Essa classe será referenciada nas classes de teste que irão compartilhar instâncias de objetos definidos no WebApplicationCollectionFixture para evitar a recriação desses objetos a cada teste.

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

Implementando o teste

Primeiro iremos criar a classe BaseIntegrationTest vai ser responsável por abstrair e compartilhar a instancia de alguns objetos entre os casos de teste.

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

A classe base possui apenas um cliente http para realizarmos as chamadas a API, a interface ISender que é responsável pelo envio de Commands e Queries em abordagem utilizando CQRS com MediatR, e o ApplicationContext que representa o contexto do nosso banco de dados, aqui utilizando o Entity Framework.

Agora iremos escrever o caso de teste, que deverá registrar uma conta de usuário quando os dados forem válidos.

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

Feito isso podemos executar nosso teste e ver toda a automação na prática.

Integrando e entregando…

Para finalizar vamos criar um pipeline no GitHub Actions para integrar nosso teste no fluxo de CI/CD. Segue abaixo um modelo para a execução dos testes dentro de um 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

Conclusão

A automação de testes de integração utilizando TestContainers e Docker oferece uma solução robusta e eficiente para lidar com dependências externas durante o desenvolvimento e execução de testes. Destacamos vantagens como o isolamento de testes, garantindo que os contêineres sejam iniciados apenas durante a execução dos testes, proporcionando um ambiente consistente a cada execução. Além disso, a reprodutibilidade é assegurada, independentemente do ambiente de execução, o que contribui para a confiabilidade dos resultados.

Compartilhe esse post