Introdução
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
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?
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?
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.
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.
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
O teste faz as seguintes ações:
- Cria e inicia um container MySql;
- Cria um Entity Framework DbContext usando a ConnectionString do MySql no container;
- 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); - Cria um objeto aleatório com AutoFixture e o adiciona à tabela do banco de dados;
- Substitui a configuração do nosso aplicativo com a string de conexão do container;
- Cria um
HttpClient apontando para o nosso aplicativo; - Faz uma solicitação para o endpoint GET passando o Id do objeto aleatório que adicionamos ao banco de dados;
- 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.

Testando o método POST
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:
- O container MySql e o DbContext são injetados pelo fixture xUnit;
- Cria um objeto aleatório com AutoFixture;
- Substitui a configuração do nosso aplicativo com a string de conexão do container;
- Cria um
HttpClient apontando para o nosso aplicativo; - Faz uma solicitação POST para o endpoint passando o objeto aleatório criado anteriormente;
- 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; - 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
https://github.com/dgenezini/TestcontainersMySqlSample