Nos dias atuais, onde a arquitetura de microsserviços e o uso de contêineres se tornaram comuns, a necessidade de realizar testes de integração confiáveis e reproduzíveis é crucial. É nesse cenário que o Testcontainers se destaca como uma ferramenta poderosa para desenvolvedores que buscam testar suas aplicações de maneira eficiente e isolada.
O que é Testcontainers?
Testcontainers é uma biblioteca de código aberto que permite aos desenvolvedores executar contêineres Docker em testes de integração. Originalmente desenvolvido para a JVM, ele agora oferece suporte para várias linguagens de programação, como Java, Python, .NET, NodeJs, entre outras. A principal função do Testcontainers é facilitar a criação de ambientes de teste realistas, usando contêineres que simulam dependências externas, como bancos de dados, filas de mensagens, navegadores web, entre outros.
Linguagens Suportadas
As linguagens podem ser acessadas Supported languages and prerequisites
Por que Usar Testcontainers?
Ambientes de Teste Isolados: Cada teste executado pelo Testcontainers é executado em um contêiner Docker isolado. Isso significa que as dependências e o estado dos testes não interferem uns com os outros, permitindo testes mais confiáveis e repetíveis.
Facilidade de Configuração: Testcontainers simplifica a configuração de ambientes complexos de teste. Em vez de configurar manualmente dependências como bancos de dados ou serviços externos, o Testcontainers permite que você defina essas dependências diretamente em seus testes, tornando o processo de configuração rápido e simples.
Reproduzibilidade: Com o Testcontainers, você pode garantir que seu ambiente de teste seja o mesmo em qualquer máquina ou pipeline CI/CD. Isso elimina problemas comuns de "funciona na minha máquina" que surgem devido a diferenças de configuração de ambiente.
Integração com Frameworks de Teste: O Testcontainers se integra facilmente com vários frameworks de teste populares, como JUnit para Java, PyTest para Python, e xUnit para .NET. Isso permite que os desenvolvedores utilizem suas ferramentas de teste preferidas enquanto aproveitam os benefícios do Testcontainers.
Como Funciona o Testcontainers?
O Testcontainers utiliza o Docker para iniciar e gerenciar contêineres que contêm as dependências necessárias para os testes. A biblioteca oferece uma API simples que permite configurar, iniciar e parar contêineres diretamente nos testes de integração. Por exemplo, ao testar uma aplicação que depende de um banco de dados MySQL, você pode configurar um contêiner MySQL diretamente no seu teste, popular o banco de dados com dados de teste e executar as verificações necessárias.
Testcontainers workflow
Um teste de integração típico baseado no Testcontainers funciona da seguinte forma:
Antes da execução do teste: Inicie os serviços necessários (bancos de dados, sistemas de mensageria, etc.) como contêineres Docker usando a API do Testcontainers. Assim que os contêineres necessários forem iniciados, configure ou atualize a configuração da sua aplicação para usar esses serviços containerizados e, opcionalmente, inicialize os dados necessários para o teste.
Durante a execução do teste: Seus testes são executados usando esses serviços containerizados.
Após a execução do teste: O Testcontainers cuida de destruir os contêineres, independentemente de os testes terem sido executados com sucesso ou se houveram falhas.
Vantagens e Desvantagens
1 - Vantagens
Ambientes Isolados: Cada teste é executado em um contêiner isolado, o que evita interferência entre testes.
Configuração Flexível: O Testcontainers permite configurar contêineres de maneira programática, facilitando a criação de ambientes complexos.
Reprodução de Bugs: Como os contêineres são idênticos em qualquer ambiente, os bugs são mais fáceis de reproduzir e resolver.
Suporte a Múltiplas Linguagens: Além de Java, o Testcontainers também suporta Python, .NET, Node.js, entre outras linguagens.
2 - Desvantagens
Dependência do Docker: Para usar o Testcontainers, você precisa ter o Docker instalado e funcionando corretamente em sua máquina ou no ambiente de CI/CD.
Tempo de Execução: Testes que utilizam Testcontainers podem demorar mais para serem executados devido ao tempo necessário para iniciar e configurar os contêineres.
Complexidade Adicional: Embora o Testcontainers simplifique a configuração de dependências, ele adiciona uma camada de complexidade ao processo de teste, que pode não ser necessária para testes simples.
Vamos colocar a mão na massa!
Vamos desenvolver uma API simples e realizar teste de integração. Será uma aplicação com a seguinte tecnologia: dotnet C#. A ideia não é focar na criação das APIs, mas sim no teste de integração da API usando para subir nossa infra o testcontainers.
Pré-requisitos:
1 - API dotnet Csharp
using Endpoints.Dtos;
using Entities;
using Services;
namespace Endpoints;
public static class ProductEndpoints
{
public static void MapProductEndpoints(this IEndpointRouteBuilder routes)
{
// GET: Retorna todos os produtos
routes.MapGet("/api/products", async (IProductService service) =>
{
var products = await service.GetProductsAsync();
return Results.Ok(products);
});
// GET: Retorna um produto pelo ID
routes.MapGet("/api/products/{id:guid}", async (Guid id, IProductService service) =>
{
var product = await service.GetProductAsync(id);
if (product == null)
return Results.NotFound();
return Results.Ok(product);
});
// POST: Cria um novo produto
routes.MapPost("/api/products", async (ProductDto product, IProductService service) =>
{
var productCreated = await service.CreateProductAsync(new Product
{
Name = product.Name,
Price = product.Price
});
return Results.Created($"/api/products/{productCreated.Id}", productCreated);
});
// PUT: Atualiza um produto existente
routes.MapPut("/api/products/{id:guid}", async (Guid id, ProductDto updatedProduct, IProductService service) =>
{
var product = await service.GetProductAsync(id);
if (product == null)
return Results.NotFound();
product.Name = updatedProduct.Name;
product.Price = updatedProduct.Price;
await service.UpdateProductAsync(product);
return Results.NoContent();
});
// DELETE: Exclui um produto
routes.MapDelete("/api/products/{id:guid}", async (Guid id, IProductService service) =>
{
var product = await service.GetProductAsync(id);
if (product == null)
return Results.NotFound();
await service.DeleteProductAsync(product.Id);
return Results.NoContent();
});
}
}
Esse trecho de código define uma série de endpoints para um sistema de gerenciamento de produtos utilizando a arquitetura de APIs RESTful no dotnet. Vamos detalhar cada parte:
Estrutura do Código
a - Namespaces e Usos:
- Endpoints.Dtos, Entities, e Services são namespaces importados, indicando a organização do código em diferentes camadas e separação de responsabilidades. O Dto (Data Transfer Object) é usado para transferir dados entre camadas, e Entities refere-se aos objetos de domínio, enquanto Services contém a lógica de negócio.
b - MapProductEndpoints:
Esse método estático estende o IEndpointRouteBuilder, que é responsável por configurar as rotas da API. Ao chamar esse método, todas as rotas relacionadas a produtos são registradas.
c - GET: /api/products:
- Esse endpoint retorna a lista de todos os produtos.
- Ele chama o método GetProductsAsync de IProductService, que busca os produtos do banco de dados.
- A resposta é enviada com o status HTTP 200 (OK) junto com a lista de produtos.
d - GET: /api/products/{id:guid}:
- Busca um produto específico pelo seu id, que deve ser um GUID (Globally Unique Identifier).
- Se o produto não for encontrado, o método retorna o status 404 (Not Found). Caso seja encontrado, retorna 200 (OK) com o produto.
e - POST: /api/products:
- Cria um novo produto.
- O ProductDto é o objeto recebido como entrada, que contém os dados necessários para criar o produto (por exemplo, Name e Price).
- O serviço CreateProductAsync é chamado, que salva o novo produto no banco de dados.
- Se bem-sucedido, retorna o status 201 (Created) com a URL do novo recurso criado.
f - PUT: /api/products/{id:guid}:
- Atualiza um produto existente identificado pelo id.
- O produto é primeiro buscado via GetProductAsync. Se não encontrado, retorna 404.
- Caso contrário, atualiza as propriedades Name e Price com os valores do updatedProduct, e chama UpdateProductAsync para salvar as alterações.
- Retorna o status 204 (No Content) para indicar sucesso sem conteúdo no corpo da resposta.
g - DELETE: /api/products/{id:guid}:
- Exclui um produto específico.
- O produto é primeiro buscado pelo id. Se não for encontrado, retorna 404.
- Se encontrado, chama DeleteProductAsync para removê-lo e retorna 204 (No Content).
Padrões Utilizados
- RESTful: As rotas seguem o padrão RESTful, onde cada ação (GET, POST, PUT, DELETE) corresponde a uma operação CRUD (Create, Read, Update, Delete).
- Injeção de Dependência: Os serviços, como IProductService, são passados diretamente nos métodos de rota, utilizando a injeção de dependência do dotnet, o que facilita a troca de implementações e promove um código mais testável.
- DTOs: O uso de ProductDto separa a camada de apresentação (dados enviados e recebidos pela API) da camada de domínio (os objetos reais de banco de dados), o que melhora a segurança e a manutenção do código.
2 - Serviço
Ela é responsável por fornecer a lógica de negócio para as operações CRUD (Create, Read, Update, Delete) relacionadas aos produtos, utilizando o Entity Framework Core para interagir com o banco de dados.
using Entities;
using Infra;
using Microsoft.EntityFrameworkCore;
namespace Services;
public class ProductService : IProductService
{
private readonly AppDbContext _context;
public ProductService(AppDbContext context)
{
_context = context;
}
public async Task<IEnumerable<Product>> GetProductsAsync()
{
return await _context.Products.ToListAsync();
}
public async Task<Product?> GetProductAsync(Guid id)
{
return await _context.Products.FirstOrDefaultAsync(x => x.Id == id);
}
public async Task<Product> CreateProductAsync(Product product)
{
product.CreatedOnUtc = DateTime.UtcNow;
_context.Products.Add(product);
await _context.SaveChangesAsync();
return product;
}
public async Task UpdateProductAsync(Product product)
{
_context.Products.Update(product);
await _context.SaveChangesAsync();
}
public async Task DeleteProductAsync(Guid id)
{
var product = await _context.Products.FirstOrDefaultAsync(x => x.Id == id);
if (product != null)
{
_context.Products.Remove(product);
await _context.SaveChangesAsync();
}
}
}
3 - Testes
Agota que temos a API, podemos realizar o teste de integração. Para nosso exemplo, um teste de integração verifica se diferentes partes de um sistema funcionam corretamente quando integradas. No contexto de uma API que utiliza banco de dados, um teste de integração comum envolve verificar se os serviços interagem corretamente com o banco de dados, confirmando que as operações CRUD (Create, Read, Update, Delete) funcionam conforme o esperado.
para esse exemplo vou usar xUnit como o framework de teste e também as libs:
Adicione a seguinte dependência ao seu arquivo de projeto:
dotnet add package Microsoft.AspNetCore.Mvc.Testing --version 8.0.8
dotnet add package Testcontainers.MsSql
dotnet add package FluentAssertions --version 6.12.1
Como para persistir dados vamos usar o Microsoft SQL Server, também conhecido como MSSQL, é um mecanismo de banco de dados relacional desenvolvido pela Microsoft e é uma escolha popular em sistemas empresariais.
O nosso código de teste segue:
3.1 - Classe IntegrationTestWebAppFactory
O código configura um ambiente de teste de integração para uma aplicação ASP.NET Core utilizando o Testcontainers para criar e gerenciar um banco de dados SQL Server em contêineres Docker
using DotNet.Testcontainers.Builders;
using Infra;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.MsSql;
namespace Tests;
public class IntegrationTestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MsSqlContainer _dbContainer = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("1q2w3e4r@#$!")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureTestServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor is not null)
{
services.Remove(descriptor);
}
services.AddDbContext<AppDbContext>(options =>
{
var connectionString = _dbContainer.GetConnectionString();
options.UseSqlServer(connectionString);
});
});
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
}
public async new Task DisposeAsync()
{
await _dbContainer.StopAsync();
}
}
Estrutura do Código
a - Imports e Namespaces:
DotNet.Testcontainers.Builders: Biblioteca para construir e gerenciar contêineres Docker.
Infra: Namespace que provavelmente contém a classe AppDbContext.
Microsoft.AspNetCore.Hosting, Microsoft.AspNetCore.Mvc.Testing, Microsoft.AspNetCore.TestHost, Microsoft.EntityFrameworkCore, Microsoft.Extensions.DependencyInjection: Bibliotecas necessárias para a criação e configuração de testes de integração ASP.NET Core e acesso ao banco de dados.
Testcontainers.MsSql: Biblioteca específica para trabalhar com contêineres Docker do SQL Server.
b - Classe IntegrationTestWebAppFactory:
Herda de WebApplicationFactory, que é usada para configurar e criar uma instância do aplicativo para testes.
Implementa a interface IAsyncLifetime, que permite definir métodos assíncronos de inicialização e limpeza.
c - Propriedade _dbContainer:
MsSqlContainer: Configuração para criar e gerenciar um contêiner do SQL Server.
MsSqlBuilder: Construtor para criar a instância do contêiner.
.WithImage("mcr.microsoft.com/mssql/server:2022-latest"): Define a imagem do Docker para o SQL Server.
.WithPassword("1q2w3e4r@#$!"): Define a senha do administrador do banco de dados.
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433)): Configura a estratégia de espera para garantir que o contêiner esteja pronto antes de prosseguir. O SQL Server escuta na porta 1433.
.Build(): Constrói a instância do contêiner.
d - Método ConfigureWebHost:
UseEnvironment("Testing"): Configura o ambiente para "Testing", que pode alterar o comportamento da aplicação e as configurações de serviços.
ConfigureTestServices: Substitui a configuração de serviços padrão com uma configuração personalizada para testes.
Remove: Remove o registro existente do DbContextOptions, se existir.
AddDbContext: Adiciona e configura o contexto do banco de dados para usar a conexão do contêiner do SQL Server.
e - Método InitializeAsync:
Inicia o contêiner do SQL Server de forma assíncrona antes dos testes começarem.
f - Método DisposeAsync:
Para o contêiner do SQL Server de forma assíncrona após os testes serem concluídos, garantindo a limpeza dos recursos utilizados.
Explicação:
Testcontainers: A biblioteca Testcontainers permite a criação e gerenciamento de contêineres Docker durante os testes, o que é útil para criar ambientes de teste isolados e replicáveis. Aqui, ela é usada para criar um contêiner *SQL Server * que simula o ambiente de banco de dados real durante os testes.
WebApplicationFactory: Facilita a criação de uma instância do aplicativo ASP.NET Core para testes, configurando o ambiente de teste e injetando dependências conforme necessário.
Configuração do Banco de Dados para Testes:
Ao configurar o AppDbContext para usar o banco de dados dentro do contêiner, você garante que os testes não interfiram no banco de dados de produção ou desenvolvimento.
Gerenciamento de Ciclo de Vida do Contêiner:
Inicialização: Garante que o banco de dados esteja pronto antes dos testes começarem.
Limpeza: Para o banco de dados depois dos testes, liberando os recursos alocados.
3.2 - Classe BaseIntegrationTest
O código define uma classe abstrata chamada BaseIntegrationTest, que é projetada para fornecer uma base para testes de integração que envolvem a comunicação com uma API. A classe utiliza o xUnit para testes e configurações de integração. Vamos detalhar cada parte do código. A classe BaseIntegrationTest fornece métodos comuns para interagir com a API durante os testes de integração, incluindo a criação e recuperação de produtos. Utiliza o cliente HTTP configurado para enviar e receber dados da API e processa as respostas JSON para objetos C#. Isso facilita a escrita de testes de integração ao oferecer uma base reutilizável e configurada adequadamente para interagir com a API.
using Entities;
using Newtonsoft.Json;
namespace Tests;
public abstract class BaseIntegrationTest : IClassFixture<IntegrationTestWebAppFactory>
{
public HttpClient _client = null!;
public BaseIntegrationTest(IntegrationTestWebAppFactory factory)
{
_client = factory.CreateClient();
_client.BaseAddress = new Uri("https://localhost");
}
public HttpClient GetClient()
{
return _client;
}
public async Task<Product> CreateProductAsync()
{
var product = new Product
{
Name = "Product 1",
Price = 10
};
var client = GetClient();
var response = await client.PostAsync("/api/products", product.ToStringContent());
await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync();
var productCreated = JsonConvert.DeserializeObject<Product>(json);
return productCreated ?? product;
}
public async Task<Product> GetProductAsync(HttpResponseMessage response)
{
var json = await response.Content.ReadAsStringAsync();
var product = JsonConvert.DeserializeObject<Product>(json);
return product ?? new Product();
}
public async Task<IEnumerable<Product>> GetProductsAsync(HttpResponseMessage response)
{
var json = await response.Content.ReadAsStringAsync();
var products = JsonConvert.DeserializeObject<IEnumerable<Product>>(json);
return products ?? Enumerable.Empty<Product>();
}
}
Estrutura do Código
a - Namespaces e Imports:
Entities: Contém a definição da classe Product.
Newtonsoft.Json: Biblioteca para serialização e desserialização de JSON.
b - Classe BaseIntegrationTest:
- Herança: Herda de IClassFixture, o que significa que a classe utiliza o IntegrationTestWebAppFactory para configurar a aplicação e criar uma instância do cliente HTTP para os testes.
c - Construtor BaseIntegrationTest:
IntegrationTestWebAppFactory factory: Recebe uma instância da fábrica de testes que configura o ambiente de teste.
_client: Cria um cliente HTTP usando a fábrica e define a base URL para https://localhost. Esse cliente é usado para fazer solicitações HTTP para a API durante os testes.
public BaseIntegrationTest(IntegrationTestWebAppFactory factory)
{
_client = factory.CreateClient();
_client.BaseAddress = new Uri("https://localhost");
}
d - Método GetClient:
Retorna o cliente HTTP configurado, que pode ser usado pelos métodos de teste para fazer solicitações.
public HttpClient GetClient()
{
return _client;
}
3.3 - A classe de teste:
No arquivo ProductTests.cs **que criamos os testes que ao ser executado vai levantar nosso infra de contêineres, no exemplo só temos o banco de dados **SQL Server, porém poderia ser RabbitMQ, Redis e vários outros que podem ser visto em Modules.
using System.Net;
using Entities;
using FluentAssertions;
namespace Tests;
public class ProductTests : BaseIntegrationTest
{
public ProductTests(IntegrationTestWebAppFactory factory) : base(factory)
{
}
[Fact(DisplayName = "Obter todosprodutos")]
[Trait("Categoria", "Produto")]
public async Task GetAll_ShouldReturnProduct_WhenProductExists()
{
// Arrange
var client = GetClient();
// Act
var response = await client.GetAsync("/api/products");
await response.Content.ReadAsStringAsync();
var products = await GetProductsAsync(response);
// Assert
products.Should().NotBeNullOrEmpty();
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact(DisplayName = "Obter produto pelo Id")]
[Trait("Categoria", "Produto")]
public async Task Get_ShouldReturnProduct_WhenProductExists()
{
// Arrange
var product = await CreateProductAsync();
var client = GetClient();
// Act
var response = await client.GetAsync($"/api/products/{product.Id}");
await response.Content.ReadAsStringAsync();
// Assert
response.Content.Should().NotBeNull();
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact(DisplayName = "Criar novo produto")]
[Trait("Categoria", "Produto")]
public async Task Create_ShouldCreateProduct()
{
// Arrange
var product = new Product
{
Name = "Product 1",
Price = 10
};
var client = GetClient();
// Act
var response = await client.PostAsync("/api/products", product.ToStringContent());
await response.Content.ReadAsStringAsync();
// Assert
response.Content.Should().NotBeNull();
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
[Fact(DisplayName = "Atualizar produto")]
[Trait("Categoria", "Produto")]
public async Task Update_ShouldThrow_WhenProductIsNull()
{
// Arrange
var product = await CreateProductAsync();
product.Name = "Product Updated";
var client = GetClient();
// Act
var response = await client.PutAsync($"/api/products/{product.Id}", product.ToStringContent());
await response.Content.ReadAsStringAsync();
var responseGet = await client.GetAsync($"/api/products/{product.Id}");
await responseGet.Content.ReadAsStringAsync();
var productGet = await GetProductAsync(responseGet);
// Assert
response.Content.Should().NotBeNull();
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
responseGet.Content.Should().NotBeNull();
responseGet.StatusCode.Should().Be(HttpStatusCode.OK);
productGet.Should().NotBeNull();
product.Name.Should().Be(productGet.Name);
}
[Fact(DisplayName = "Deletar produto")]
[Trait("Categoria", "Produto")]
public async Task Delete_ShouldDeleteProduct_WhenProductExists()
{
// Arrange
var product = await CreateProductAsync();
var client = GetClient();
// Act
var response = await client.DeleteAsync($"/api/products/{product.Id}");
await response.Content.ReadAsStringAsync();
// Assert
response.Content.Should().NotBeNull();
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
}
Para executar os testes:
dotnet test
Saída:
Iniciando execução de teste, espere...
1 arquivos de teste no total corresponderam ao padrão especificado.
Aprovado! – Com falha: 0, Aprovado: 5, Ignorado: 0, Total: 5, Duração: 46 ms - Tests.dll (net8.0)
Os contêineres criados para o teste:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6f98a2e38263 mcr.microsoft.com/mssql/server:2022-latest "/opt/mssql/bin/perm…" 1 second ago Up Less than a second 0.0.0.0:32781->1433/tcp lucid_kilby
8ca5671ec0b4 testcontainers/ryuk:0.6.0 "/bin/ryuk" 1 second ago Up Less than a second 0.0.0.0:32780->8080/tcp testcontainers-ryuk-74611f97-e6cd-4363-9b5f-7d63c17f1147
Testcontainers/Ryuk é um componente utilizado pela biblioteca Testcontainers para gerenciar o ciclo de vida de contêineres durante os testes. Ryuk é uma ferramenta auxiliar que se assegura de que todos os contêineres iniciados durante a execução de testes sejam parados e removidos quando os testes são concluídos, evitando que os contêineres deixem recursos "pendentes" no sistema.
Detalhes sobre Ryuk
Ryuk foi projetado para ser uma solução leve e eficaz para a limpeza automática dos contêineres Docker que são criados e usados durante os testes. Ele é especialmente útil em ambientes de CI/CD e desenvolvimento local, onde muitos testes podem iniciar e parar contêineres frequentemente.
Para quem gosta de anime vai lembra Ryuk é um personagem do anime e mangá Death Note. Ryuk é um shinigami ("deus da morte", em japonês).
O código completo pode ser acessado: Products Api dotnet Testcontainers
Conclusão
O Testcontainers é uma ferramenta valiosa para quem precisa realizar testes de integração confiáveis em ambientes complexos. Ele traz uma série de benefícios, como ambientes de teste isolados, facilidade de configuração e reprodução garantida de ambientes. Contudo, é importante estar ciente das suas limitações, como a dependência do Docker e o tempo de execução dos testes.
Para projetos que dependem de várias dependências externas ou precisam de ambientes de teste consistentes e reproduzíveis, o Testcontainers é uma excelente escolha que pode melhorar significativamente a qualidade e a confiabilidade dos seus testes de integração.
Referências:
Top comments (1)
Excelente post, bem completo. Obrigado por compartilhar o conhecimento @wandealves