Automatizando Testes de Integração com TestContainers e Docker

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:
- 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.
- Reprodutibilidade: Ao utilizar contêineres, você garante que o ambiente de teste seja reproduzível, independentemente do ambiente de execução.
- 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
O 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 peloWebApplicationFactory
.
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.