Featured image of post Como executar bancos de dados descartáveis para seus testes. Melhore a precisão de seus testes de integração com Testcontainers

Como executar bancos de dados descartáveis para seus testes. Melhore a precisão de seus testes de integração com Testcontainers

Criando bancos de dados descartáveis com Testcontainers

Introdução Link to this section

Testes de integração são essenciais para garantir que os diferentes componentes do nosso sistema funcionem juntos como esperado e continuem funcionando após as mudanças.

Neste post, explicarei como criar containers de banco de dados descartáveis para usar em testes de integração.

Testes de integração e recursos gerenciados Link to this section

Como explicado neste artigo, em testes de integração, devemos simular dependências não gerenciadas (dependências que são externas ao nosso sistema e não controladas por nós, como APIs), mas testar em relação a dependências gerenciadas reais (dependências que são controladas pelo nosso sistema, como bancos de dados). Isso melhora a confiabilidade dos testes de integração porque a comunicação com essas dependências é uma parte complexa do sistema e pode quebrar com uma atualização de pacote, uma atualização de banco de dados ou até mesmo uma simples alteração em um comando SQL.

O que é Testcontainers? Link to this section

Testcontainers é uma biblioteca que fornece instâncias leves e descartáveis de bancos de dados, navegadores da web selenium ou qualquer coisa que possa ser executada em um container. Essas instâncias podem ser especialmente úteis para testar aplicativos em relação a dependências reais, como bancos de dados, que podem ser criados e descartados após os testes.

Por que não executar os containers manualmente? Link to this section

Um dos principais benefícios de usar Testcontainers em vez de executar os containers manualmente é que podemos usar bibliotecas como o AutoFixture para gerar dados para popular o banco de dados em vez de executar scripts para inserir os dados. Isso também ajuda a evitar a colisão entre os dados usados em diferentes testes porque os dados são aleatórios.

Além disso, existem outras vantagens em usar Testcontainers:

  • Para executar os testes localmente, você não precisa de nenhuma etapa extra, como executar um comando docker-compose;
  • Você não precisa esperar ou implementar uma estratégia de espera para verificar se os containers estão em execução antes de acessá-los. O Testcontainers já implementa essa lógica;
  • O Testcontainers tem propriedades para acessar a porta em que o container está em execução, então você não precisa especificar uma porta fixa, evitando conflitos de porta ao executar no pipeline CI/CD ou em outras máquinas;
  • O Testcontainers para e exclui os containers automaticamente após a execução, liberando recursos na máquina.

Executando um container descartável com Testcontainers Link to this section

Para usar o Testcontainers, você precisará ter um tempo de execução de container (Docker, Podman, Rancher, etc) instalado em sua máquina.

Em seguida, você precisa adicionar o pacote NuGet Testcontainers ao seu projeto de teste:

dotnet add package Testcontainers --version 3.1.0
dotnet add package Testcontainers.MySql --version 3.1.0

⚠️ Desde a escrita deste post, o Testcontainers lançou a versão 3.1.0 e fez mudanças significativas. Estou atualizando este post para refletir essas mudanças. Para ver o código original com Testcontainers 2.3.*, procure neste branch do repositório.

Para executar um container, primeiro precisamos usar a classe ContainerBuilder para construir um DockerContainer ou uma classe derivada, por exemplo, MySqlBuilder:

1
2
3
4
5
await using var mySqlContainer = new MySqlBuilder()
    .WithDatabase("TestDB")
    .WithPassword("Test1234")
    .WithImage("mysql:8.0.31-oracle")
    .Build();

Em seguida, iniciamos o container e usamos o método GetConnectionString onde necessário:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
await using var mySqlContainer = new MySqlBuilder()
    .WithDatabase("TestDB")
    .WithPassword("Test1234")
    .WithImage("mysql:8.0.31-oracle")
    .Build();

await mySqlContainer.StartAsync();

await using var todoContext = TodoContext
    .CreateFromConnectionString(mySqlContainer.GetConnectionString());

DockerContainer implementa IAsyncDisposable e precisa ser descartado após o uso. Podemos usar a sintaxe await using ou chamar o método DisposeAsync.

Testcontainers tem classes para muitos bancos de dados diferentes (chamados de módulos), por exemplo:

  • Elasticsearch;
  • MariaDB;
  • Microsoft SQL Server;
  • MySQL;
  • Redis.

A lista completa pode ser vista aqui.

Mas também podemos criar containers a partir de qualquer imagem, como no exemplo abaixo, onde ele cria uma instância Memcached:

1
2
3
4
5
await using var MemCachedContainer = new ContainerBuilder()
    .WithImage("memcached:1.6.17")
    .Build();

await MemCachedContainer.StartAsync();

💡 Neste post, explico como usar Testcontainers com Localstack para criar testes de integração para sistemas que usam serviços AWS.

Mais detalhes na documentação oficial.

Criando testes de integração com Testcontainers Link to this section

Neste exemplo, estou usando o xUnit e a classe WebApplicationFactory<T> do ASP.NET Core.

Se você não sabe como usar a classe WebApplicationFactory<T>, expliquei neste post.

Neste exemplo, tenho um controlador com um método GET que retorna um item ToDo e um método POST que adiciona um item ToDo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[ApiController]
[Route("[controller]")]
public class TodoController : ControllerBase
{
    private readonly TodoContext _todoContext;

    public TodoController(TodoContext todoContext)
    {
        _todoContext = todoContext;
    }

    [HttpGet("{itemId}", Name = "GetTodoItem")]
    public async Task<ActionResult<TodoItem?>> GetByIdAsync(int itemId)
    {
        var item = await _todoContext.TodoItems.SingleOrDefaultAsync(a => a.ItemId == itemId);

        if (item is null)
        {
            return NotFound();
        }

        return Ok(item);
    }

    [HttpPost]
    public async Task<ActionResult<int>> PostAsync(TodoItem todoItem)
    {
        _todoContext.Add(todoItem);

        await _todoContext.SaveChangesAsync();
        
        return CreatedAtRoute("GetTodoItem", new { itemId = todoItem.ItemId }, todoItem);
    }
}

⚠️ A lógica de negócios está no controlador apenas por uma questão de simplicidade. Em um aplicativo do mundo real, a lógica deve estar em outra camada.

Testando o método GET Link to this section

O teste faz as seguintes ações:

  1. Cria e inicia um container MySql;
  2. Cria um Entity Framework DbContext usando a ConnectionString do MySql no container;
  3. Cria as tabelas do banco de dados usando o Entity Framework; (Também pode ser feito passando um script para o método ExecScriptAsync do objeto mySqlContainer);
  4. Cria um objeto aleatório com AutoFixture e o adiciona à tabela do banco de dados;
  5. Substitui a configuração do nosso aplicativo com a string de conexão do container;
  6. Cria um HttpClient apontando para o nosso aplicativo;
  7. Faz uma solicitação para o endpoint GET passando o Id do objeto aleatório que adicionamos ao banco de dados;
  8. Valida que o Código de Status é 200 e que o objeto retornado é o mesmo que adicionamos ao banco de dados.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class TodoIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public TodoIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task GetOneItem_Returns200WithItem()
    {
        //Arrange
        await using var mySqlContainer = new MySqlBuilder()
            .WithDatabase("TestDB")
            .WithPassword("Test1234")
            .WithImage("mysql:8.0.31-oracle")
            .Build();

        await mySqlContainer.StartAsync();

        await using var todoContext = TodoContext
            .CreateFromConnectionString(mySqlContainer.GetConnectionString());

        // Creates the database if not exists
        await todoContext.Database.EnsureCreatedAsync();

        Fixture fixture = new Fixture();
        var todoItem = fixture.Create<TodoItem>();

        todoContext.TodoItems.Add(todoItem);

        await todoContext.SaveChangesAsync();

        var HttpClient = _factory
            .WithWebHostBuilder(builder =>
            {
                builder.UseSetting("MySqlConnectionString", mySqlTestcontainer.ConnectionString);
            })
            .CreateClient();

        //Act
        var HttpResponse = await HttpClient.GetAsync($"/todo/{todoItem.ItemId}");

        //Assert
        HttpResponse.StatusCode.Should().Be(HttpStatusCode.OK);

        var responseJson = await HttpResponse.Content.ReadAsStringAsync();
        var todoItemResult = JsonSerializer.Deserialize<TodoItem>(responseJson);

        todoItemResult.Should().BeEquivalentTo(todoItem);
    }
}

❗ Esteja ciente de que passo a versão da tag do container no método WithImage mesmo ao usar a classe tipada MySqlBuilder. Isso é muito importante porque quando não passamos a tag, o tempo de execução do container usará a tag latest e uma atualização do banco de dados pode quebrar o aplicativo e os testes.

Testes em execução e ciclo de vida do container

Testando o método POST Link to this section

Primeiro, vamos migrar o MySqlContainer e a criação do DbContext para um Fixture e uma Collection para que possam ser compartilhados entre todos os testes. Isso é recomendado porque, a menos que façamos isso, todos os testes criarão e descartarão o container, tornando nossos testes mais lentos do que o necessário.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[CollectionDefinition("MySqlTestcontainer Collection")]
public class MySqlTestcontainerCollection: ICollectionFixture<MySqlTestcontainerFixture>
{
}

public class MySqlTestcontainerFixture : IAsyncLifetime
{
    public MySqlContainer MySqlContainer { get; private set; } = default!;
    public TodoContext TodoContext { get; private set; } = default!;

    public async Task InitializeAsync()
    {
        MySqlContainer = new MySqlBuilder()
            .WithDatabase("TestDB")
            .WithPassword("Test1234")
            .WithImage("mysql:8.0.31-oracle")
            .Build();

        await MySqlContainer.StartAsync();

        TodoContext = TodoContext
            .CreateFromConnectionString(MySqlContainer.GetConnectionString());

        // Creates the database if it does not exists
        await TodoContext.Database.EnsureCreatedAsync();
    }

    public async Task DisposeAsync()
    {
        await MySqlContainer.DisposeAsync();

        await TodoContext.DisposeAsync();
    }
}

O teste faz as seguintes ações:

  1. O container MySql e o DbContext são injetados pelo fixture xUnit;
  2. Cria um objeto aleatório com AutoFixture;
  3. Substitui a configuração do nosso aplicativo com a string de conexão do container;
  4. Cria um HttpClient apontando para o nosso aplicativo;
  5. Faz uma solicitação POST para o endpoint passando o objeto aleatório criado anteriormente;
  6. Valida que o Código de Status é 200 e que o cabeçalho Location tem o URL correto para o endpoint GET do objeto criado;
  7. Consulta o banco de dados e valida que o objeto criado é igual ao objeto criado aleatoriamente.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
[Collection("MySqlTestcontainer Collection")]
public class TodoIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly MySqlContainer _mySqlContainer;
    private readonly TodoContext _todoContext;

    public TodoIntegrationTests(WebApplicationFactory<Program> factory, 
        MySqlTestcontainerFixture mySqlTestcontainerFixture)
    {
        _factory = factory;
        _mySqlContainer = mySqlTestcontainerFixture.MySqlContainer;
        _todoContext = mySqlTestcontainerFixture.TodoContext;
    }

    //Outros testes
    //...

    [Fact]
    public async Task PostOneItem_Returns201AndCreateItem()
    {
        //Arrange
        Fixture fixture = new Fixture();
        var todoItem = fixture.Build<TodoItem>()
            .With(x => x.ItemId, 0)
            .Create();

        var HttpClient = _factory
            .WithWebHostBuilder(builder =>
            {
                builder.UseSetting("MySqlConnectionString", _mySqlContainer.GetConnectionString());
            })
            .CreateClient();

        //Act
        var HttpResponse = await HttpClient.PostAsJsonAsync($"/todo", todoItem);

        //Assert
        HttpResponse.StatusCode.Should().Be(HttpStatusCode.Created);

        var responseJson = await HttpResponse.Content.ReadAsStringAsync();
        var todoItemResult = JsonSerializer.Deserialize<TodoItem>(responseJson);

        HttpResponse.Headers.Location.Should().Be($"{HttpClient.BaseAddress}Todo/{todoItemResult!.ItemId}");

        var dbItem = await _todoContext.TodoItems
            .SingleAsync(a => a.ItemId == todoItemResult!.ItemId);

        dbItem.Description.Should().Be(todoItem.Description);
    }
}

Código fonte desta amostra Link to this section

https://github.com/dgenezini/TestcontainersMySqlSample

💬 Like or have something to add? Leave a comment below.
Ko-fi
GitHub Sponsor
Licensed under CC BY-NC-SA 4.0
Criado com Hugo
Tema Stack desenvolvido por Jimmy