Testes de integração em .NET com Testcontainers e XUnit

Leonardo Guilen
14 min readJan 5, 2024

Entendendo como implementar testes de integração em aplicações .NET e como ajudam a melhorar a qualidade de código e mitigar precocemente o envio de bugs para produção.

Introdução

Os testes de integração desempenham um papel crucial no desenvolvimento de software, pois visam verificar se as diferentes partes de um sistema colaboram de forma eficiente e sem falhas. Eles representam uma camada fundamental de garantia de qualidade, assegurando que módulos, componentes e serviços interajam de maneira coesa, contribuindo para a robustez do software.

Em um cenário complexo de desenvolvimento, onde temos múltiplos componentes se integrando para formar uma aplicação funcional, os testes de integração desempenham um papel crucial. Eles identificam potenciais conflitos entre diferentes partes do código, garantindo que as alterações em um componente não comprometam o funcionamento integrado do sistema (Testes Regressivos). Ao realizar testes nesse nível, os desenvolvedores podem detectar e corrigir problemas antes que impactem o usuário final, contribuindo para a estabilidade e confiabilidade da aplicação.

Testcontainers + XUnit

No contexto do desenvolvimento .NET, a combinação do pacote Testcontainers com a estrutura de testes XUnit oferece uma abordagem eficaz para a implementação de testes de integração. O Testcontainers simplifica a criação e gerenciamento de ambientes de teste contendo dependências externas, como bancos de dados ou serviços, facilitando a simulação de condições do mundo real. Integrado ao XUnit, proporciona uma maneira organizada e eficiente de escrever, executar e analisar os resultados dos testes de integração, fortalecendo o ciclo de desenvolvimento.

Configurando a base de um teste de integração com XUnit

Desempacotando projeto de API

Vamos começar com um exemplo bem simples, iniciando pela criação de um novo projeto, mais especificamente uma Minimal API, usando a CLI do dotnet.

dotnet new webapi -n Transactions.Api -minimal -o src/Transactions.Api

Após isso, temos um template padrão desempacotado. Como a ideia é começar bem simples, alteraremos o program.cs incluindo o seguinte código:

// #Program.cs

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

Podemos executar a aplicação e fazer uma chamada ao endpoint via CURL para garantir que está tudo certo até aqui.

> curl -i localhost:5117

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Tue, 02 Jan 2024 22:50:01 GMT
Server: Kestrel
Transfer-Encoding: chunked

Hello World!

Criando projeto e teste de integração para esse endpoint

Para o projeto de teste, seguiremos a mesma estratégia acima, usando a CLI do dotnet para nos auxiliar com isso.

dotnet new xunit -n Transactions.Api.IntegrationTest -o tests/Transactions.Api.IntegrationTest

Instale os seguintes pacotes que serão utilizados na demo:

FluentAssertions: dotnet add tests/Transactions.Api.IntegrationTest package FluentAssertions
Microsoft.AspNetCore.Mvc.Testing: dotnet add tests/Transactions.Api.IntegrationTest package Microsoft.AspNetCore.Mvc.Testing

Com o projeto criado, primeira passo é criar o nosso WebApplicationFactory customizado para os testes.

Mas antes devemos fazer uma alteração bem simples no program.cs, tornando a classe Program customizavél e visivél em outros assemblies

// #Program.cs

...

// Inclua essa linha no final do arquivo
public partial class Program { }

De volta ao projeto de teste, criaremos uma classe nomeada CustomWebApplicationFactory que fará referencia a classe Program original, porém, iremos sobrescrever a implementação do metódo de configuração do WebHost

// #CustomWebApplicationFactory.cs

public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseTestServer();
}
}

Por hora, só estamos habilitando o serviço do TestServer que utilizaremos para criar um servidor http dedicado para os testes.

Agora, podemos seguir para a implementação dos testes, criaremos um arquivo chamado EndpointIntegrationTest, decoraremos essa classe com a interface IClassFixture e implementaremos um caso de teste.

Caso precise entender melhor sobre como funciona o compartilhamento de contexto entre testes no XUnit, leia a seguinte documentação: Shared Context between Tests: https://xunit.net/docs/shared-context

// #EndpointIntegrationTest.cs

public class EndpointIntegrationTest(CustomWebApplicationFactory factory) : IClassFixture<CustomWebApplicationFactory>
{
[Fact]
public async Task GetEndpoint_GivenRequestReceived_ThenReturnsOKWithHelloWorldContent()
{
// Arrange
var client = factory.Server.CreateClient();
var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("Hello World!"),
};

// Act
var response = await client.GetAsync("/");

// Assert
response.Should().BeEquivalentTo(expectedResponse, cfg => cfg.Excluding(x => x.RequestMessage));
}
}

Ao executar o teste, podemos ver que o teste é validado, e isso significa que a configuração está correta

> dotnet test --filter "DisplayName~GetEndpoint_GivenRequestReceived_Th
enReturnsOKWithHelloWorldContent"

Determining projects to restore...
...
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: < 1 ms - IntegrationTestDemo.IntegrationTest.dll (net8.0)

Feito! Temos uma base pronta para os proximos testes que iremos implementar.

Abordando cenários complexos e trazendo o Testcontainers para o jogo

Aproximando o contexto de um caso real do dia-a-dia

Dado um cenário onde temos que processar transações financeiras de nossos clientes. Uma abordagem comum envolve a validação de dados, verificação de saldo e a execução das transações propriamente ditas, que no nosso caso, será registrar a transação no banco de dados e publicar um evento em um broker.

A imagem abaixo demonstra como essa operação funcionaria na prática

Imagem com fluxo operacional do processamento de transações
Diagrama com fluxo operacional de uma simulação de processamento de transações

Analisando esse contexto, temos alguns requisitos funcionais de negócio que precisariam ser validados no serviço de transações, como:

  • Dado uma requisição com um payload contendo campos inválidos ou valores fora de um range aceitavél, então, um erro 400 (Bad Request) deve ser retornado.
  • Dado uma requisição com uma conta que não exista no serviço externo de contas de clientes, então, um erro 422 (Unprocessable Entity) deve ser retornado.
  • Dado uma requisição com um valor maior que o saldo atual da conta do cliente, então, um erro 422 (Unprocessable Entity) deve ser retornado.
  • Dado uma requisição válida e o cliente tenha saldo suficiente, então, a transação deve ser registrada no banco de dados, um evento deve ser publicado no broker e o código 201 (Created) deve ser retornado.
  • Dado uma requisição que tenha um erro inexperado durante o processamento, então, um erro 500 (Internal Server Error) deve ser retornado.

Temos uma complexidade maior nesse cenário, agora imagine ter que refazer os testes regressivos manuais sempre que houver alguma modificação ou funcionalidade nova. Por isso da importância de implementarmos os testes de integração de cada um desses requisitos.

Endpoint e implementação do código

O arquivo Program.cs foi alterado para incluir o endpoint POST /api/transactions na API e configurar dependências do projeto

// #Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddExternalServicesHttpClients(builder.Configuration); 👈 // Adicionando cliente http para o CustomerAccountService
builder.Services.AddMessagingServices(builder.Configuration); 👈 // Adicionando serviços para publicação de mensagens no RabbitMQ
builder.Services.AddDataRepositories(builder.Configuration); 👈 // Adicionando repositórios para interação com o Postgresql
builder.Services.AddScoped<ITransactionProcessingService, TransactionProcessingService>(); 👈 // Adicionando serviço de processamento de transações

var app = builder.Build();

var api = app.MapGroup("/api");

api.MapPost( 👈 // Incluindo endpoint
pattern: "/transactions",
handler: async (
[FromBody] TransactionRequest request,
[FromServices] ITransactionProcessingService processingService,
CancellationToken requestAborted)
=> await processingService.ExecuteAsync(request, requestAborted)) 👈 // Execução do processamento
.WithName("RegisterTransaction")
.WithTags("Transactions")
.WithOpenApi();

await app
.RunAsync()
.ConfigureAwait(false);

public partial class Program { }

Não iremos entrar em detalhes das outras implementações, porém, o código completo estará disponível no repositório do meu github.
https://github.com/leoguilen/dotnet-integrationtest-testcontainer

Implementando os testes de integração

De inicio, criaremos uma classe base onde as classes de testes de integração irão herdar as funcionalidades necessárias para a execução dos testes.

// #IntegrationTest.cs

public abstract class IntegrationTest(
CustomWebApplicationFactory factory,
ITestOutputHelper outputHelper)
: IClassFixture<CustomWebApplicationFactory>
{
private readonly AsyncServiceScope _integrationTestScope = factory.Services.CreateAsyncScope();

protected HttpClient Client => factory.Server.CreateClient(); 👈 // Cliente para fazer as requisições ao TestServer
protected ITestOutputHelper Logger => outputHelper; 👈 // Logger dos testes

protected async Task VerifyTransactionRecordInDatabaseAsync(Guid transactionId)
{
var repository = _integrationTestScope.ServiceProvider.GetRequiredService<ITransactionRepository>();
var transaction = await repository.GetByIdAsync(transactionId);
transaction?.Id.Should().Be(transactionId, because: "the transaction id should match the one in the database");
}

protected void VerifyTransactionMessageInBroker(Guid transactionId)
{
var connectionFactory = _integrationTestScope.ServiceProvider.GetRequiredService<IConnectionFactory>();

using var connection = connectionFactory.CreateConnection();
using var channel = connection.CreateModel();

var message = channel.BasicGet(queue: "transactions", autoAck: false);

var transaction = JsonSerializer.Deserialize<Transaction?>(message.Body.ToArray());
transaction?.Id.Should().Be(transactionId, because: "the transaction id should match the one in the broker");
}
}

Próximo passo, implementar o teste de integração do endpoint de transactions cobrindo os requisitos de negócio.

// #TransactionsEndpointsTest.cs

[Trait("IntegrationTest", "TransactionsEndpoints")]
public class TransactionsEndpointsTest(
CustomWebApplicationFactory factory,
ITestOutputHelper outputHelper)
: IntegrationTest(factory, outputHelper)
{
private static readonly Guid _validAccountId = Guid.Parse("7fb0f9ea-ded3-4747-bb9d-4fccfc1b8c36"); 👈 // Id da conta que será configurada no WireMock

[Theory]
[MemberData(nameof(TestDataFixture.InvalidTransactionRequestTestData), MemberType = typeof(TestDataFixture))]
public async Task PostTransaction_GivenInvalidRequest_ThenReturnsBadRequest(
TransactionRequest request,
string propertyName,
string errorMessage) { ... }

[Fact]
public async Task PostTransaction_GivenInvalidAccount_ThenReturnsUnprocessableEntity() { ... }

[Fact]
public async Task PostTransaction_GivenRequestAmountGreaterThanAccountBalance_ThenReturnsUnprocessableEntity() { ... }

[Fact]
public async Task PostTransaction_GivenValidRequest_ThenReturnsCreated()
{
// Arrange
var request = TransactionRequestFixture.Instance
.WithAccountId(_validAccountId)
.Generate();

// Act
var response = await Client.PostAsJsonAsync("/api/v1/transactions", request);
Logger.WriteLine("Response: {0}", await response.Content.ReadAsStringAsync());

// Assert
using (new AssertionScope())
{
response.Should()
.HaveStatusCode(HttpStatusCode.Created).And
.Match(res => res.Headers.Location!.ToString().Equals($"/api/transactions/{request.TransactionId}"));
await VerifyTransactionRecordInDatabaseAsync(request.TransactionId);
VerifyTransactionMessageInBroker(request.TransactionId);
}
}
}

Com os testes implementados, vamos executar o ultimo PostTransaction_GivenValidRequest_ThenReturnsCreated via CLI do dotnet para ver o resultado

> dotnet test -v quiet --filter "DisplayName~PostTransaction_GivenValidRequest_ThenReturnsCreated"

Test run for /workspaces/integrationtests-testcontainer/tests/Transactions.Api.IntegrationTest/bin/Debug/net8.0/Transactions.Api.IntegrationTest.dll (.NETCoreApp,Version=v8.0)
Microsoft (R) Test Execution Command Line Tool Version 17.8.0 (x64)
Copyright (c) Microsoft Corporation. All rights reserved.

Microsoft (R) Test Execution Command Line Tool Version 17.8.0 (x64)
Copyright (c) Microsoft Corporation. All rights reserved.

Starting test execution, please wait...
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
A total of 1 test files matched the specified pattern.

info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.LogicalHandler[100]
Start processing HTTP request GET http://localhost:8080/api/v1/accounts/7fb0f9ea-ded3-4747-bb9d-4fccfc1b8c36
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.ClientHandler[100]
Sending HTTP request GET http://localhost:8080/api/v1/accounts/7fb0f9ea-ded3-4747-bb9d-4fccfc1b8c36
fail: Transactions.Api.ExternalServices.HttpCustomerAccountServiceClient[0]
Failed to fetch account with id 7fb0f9ea-ded3-4747-bb9d-4fccfc1b8c36
System.Net.Http.HttpRequestException: Cannot assign requested address (localhost:8080)
---> System.Net.Sockets.SocketException (99): Cannot assign requested address
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken)
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(Int16 token)
at System.Net.Sockets.Socket.<ConnectAsync>g__WaitForConnectWithCancellation|285_0(AwaitableSocketAsyncEventArgs saea, ValueTask connectTask, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.ConnectToTcpHostAsync(String host, Int32 port, HttpRequestMessage initialRequest, Boolean async, CancellationToken cancellationToken)
--- End of inner exception stack trace ---
at System.Net.Http.HttpConnectionPool.ConnectToTcpHostAsync(String host, Int32 port, HttpRequestMessage initialRequest, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.ConnectAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.CreateHttp11ConnectionAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.AddHttp11ConnectionAsync(QueueItem queueItem)
at System.Threading.Tasks.TaskCompletionSourceWithCancellation`1.WaitWithCancellationAsync(CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.SendWithVersionDetectionAndRetryAsync(HttpRequestMessage request, Boolean async, Boolean doRequestAuth, CancellationToken cancellationToken)
at System.Net.Http.DiagnosticsHandler.SendAsyncCore(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at Microsoft.Extensions.Http.Logging.LoggingHttpMessageHandler.<SendCoreAsync>g__Core|5_0(HttpRequestMessage request, Boolean useAsync, CancellationToken cancellationToken)
at Microsoft.Extensions.Http.Logging.LoggingScopeHttpMessageHandler.<SendCoreAsync>g__Core|5_0(HttpRequestMessage request, Boolean useAsync, CancellationToken cancellationToken)
at System.Net.Http.HttpClient.<SendAsync>g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken)
at Transactions.Api.ExternalServices.HttpCustomerAccountServiceClient.FetchByIdAsync(Guid accountId, CancellationToken cancellationToken) in /workspaces/integrationtests-testcontainer/src/Transactions.Api/ExternalServices/HttpCustomerAccountServiceClient.cs:line 14
[xUnit.net 00:00:01.45] Transactions.Api.IntegrationTest.Endpoints.TransactionEndpointsTest.PostTransaction_GivenValidRequest_ThenReturnsCreated [FAIL]

Failed! - Failed: 1, Passed: 0, Skipped: 0, Total: 1, Duration: < 1 ms - Transactions.Api.IntegrationTest.dll (net8.0)

E como esperado, deu ruim! Não temos infrastrutura necessária para executar os testes. Algumas das abordagens para resolver isso, são:

  • Criar um mock da implementação original, como uma chamada http ou consulta em banco de dados, por exemplo.
  • Subir um ambiente de teste em algum provedor de nuvem com os recursos necessários.
  • Configurar o Testcontainers para subir um ambiente local com containers. ✅

Subindo ambiente de teste com Testcontainers

Primeira coisa é saber os recursos externos que o serviço depende, como banco de dados, broker de mensageria, API’s e etc. Para essa demo, os recursos utilizados foram PostgreSQL, RabbitMQ e um serviço http.

Sabendo disso, adicionaremos os pacotes necessários para subir esses recursos localmente com docker.

dotnet add tests/Transactions.Api.IntegrationTest package Testcontainers
dotnet add tests/Transactions.Api.IntegrationTest package Testcontainers.PostgreSql
dotnet add tests/Transactions.Api.IntegrationTest package Testcontainers.RabbitMq

Para testar o serviço http utilizaremos o WireMock. Uma ferramenta ótima para construir mocks de API’s, e existe imagem no docker hub para facilitar a execução local.

Porém, ainda não existe um pacote de extensão do Testcontainers para o WireMock. Portanto, criaremos uma customização manual desse recurso no projeto de teste.

// #WireMock.cs

public sealed class WireMockContainer(
IContainerConfiguration configuration,
ILogger logger)
: DockerContainer(configuration, logger)
{
public Uri GetBaseUrl() => new UriBuilder("http", Hostname, GetMappedPublicPort(WireMockBuilder.WireMockPort)).Uri;
}

public sealed class WireMockConfiguration : ContainerConfiguration
{
public WireMockConfiguration()
{
}

public WireMockConfiguration(IResourceConfiguration<CreateContainerParameters> resourceConfiguration)
: base(resourceConfiguration)
{
}

public WireMockConfiguration(IContainerConfiguration resourceConfiguration)
: base(resourceConfiguration)
{
}

public WireMockConfiguration(WireMockConfiguration resourceConfiguration)
: this(new WireMockConfiguration(), resourceConfiguration)
{
}

public WireMockConfiguration(WireMockConfiguration oldValue, WireMockConfiguration newValue)
: base(oldValue, newValue)
{
}
}

public sealed class WireMockBuilder(WireMockConfiguration resourceConfiguration)
: ContainerBuilder<WireMockBuilder, WireMockContainer, WireMockConfiguration>(resourceConfiguration)
{
public const string WireMockImage = "wiremock/wiremock:3.3.1-1-alpine";

public const ushort WireMockPort = 8080;

public WireMockBuilder()
: this(new WireMockConfiguration())
{
DockerResourceConfiguration = base.Init().DockerResourceConfiguration;
}

protected override WireMockConfiguration DockerResourceConfiguration { get; } = resourceConfiguration;

public override WireMockContainer Build()
{
Validate();
return new WireMockContainer(Init().DockerResourceConfiguration, TestcontainersSettings.Logger);
}

protected override WireMockBuilder Init()
{
return base.Init()
.WithImage(WireMockImage)
.WithPortBinding(WireMockPort, true)
.WithEntrypoint("/docker-entrypoint.sh", "--global-response-templating", "--disable-gzip", "--verbose")
.WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(s => s
.WithMethod(HttpMethod.Get)
.ForPort(WireMockPort)
.ForPath("/__admin/health")
.ForStatusCode(HttpStatusCode.OK)));
}

protected override void Validate() => base.Validate();

protected override WireMockBuilder Clone(IContainerConfiguration resourceConfiguration)
=> Merge(DockerResourceConfiguration, new WireMockConfiguration(resourceConfiguration));

protected override WireMockBuilder Clone(IResourceConfiguration<CreateContainerParameters> resourceConfiguration)
=> Merge(DockerResourceConfiguration, new WireMockConfiguration(resourceConfiguration));

protected override WireMockBuilder Merge(WireMockConfiguration oldValue, WireMockConfiguration newValue)
=> new(new WireMockConfiguration(oldValue, newValue));
}

Pronto, estamos habilitados a subir um container do WireMock. Agora vamos criar um fixture e configurar todos os containers e o ciclo de vida deles.

// #ContainersFixture.cs

public class ContainersFixture : IAsyncLifetime
{
public ContainersFixture()
{
PostgreSqlContainer = new PostgreSqlBuilder()
.WithImage("postgres:alpine")
.WithDatabase("transactions")
.WithResourceMapping(
resourceContent: Encoding.UTF8.GetBytes(
"""
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS "TRANSACTIONS" (
"ID" UUID NOT NULL,
"ACCOUNT_ID" UUID NOT NULL,
"AMOUNT" NUMERIC(10,2) NOT NULL,
"CURRENCY" VARCHAR(3) NOT NULL,
"DATE" timestamp NOT NULL,
PRIMARY KEY ("ID")
);
INSERT INTO "TRANSACTIONS"
VALUES ('e4d875b4-e6e9-4c6f-a50b-df648b8a1969', '7fb0f9ea-ded3-4747-bb9d-4fccfc1b8c36', 100, 'EUR', '2021-01-01 00:00:00');
"""
),
filePath: "/docker-entrypoint-initdb.d/init.sql")
.WithAutoRemove(true)
.Build();

WireMockContainer = new WireMockBuilder()
.WithImage("wiremock/wiremock:latest-alpine")
.WithResourceMapping(
resourceContent: Encoding.UTF8.GetBytes(
"""
{
"id": "7fb0f9ea-ded3-4747-bb9d-4fccfc1b8c36",
"name": "test",
"balance": 100
}
"""
),
filePath: "/home/wiremock/__files/account.json")
.WithResourceMapping(
resourceContent: Encoding.UTF8.GetBytes(
"""
{
"mappings": [
{
"priority": 1,
"request": {
"urlPathTemplate": "/api/v1/accounts/{accountId}",
"method": "GET",
"pathParameters": {
"accountId": {
"equalTo": "7fb0f9ea-ded3-4747-bb9d-4fccfc1b8c36"
}
}
},
"response": {
"status": 200,
"bodyFileName": "account.json",
"headers": {
"Content-Type": "application/json"
}
}
},
{
"priority": 10,
"request": {
"urlPathTemplate": "/api/v1/accounts/{accountId}",
"method": "GET"
},
"response": {
"status": 404,
"headers": {}
}
}
]
}
"""
),
filePath: "/home/wiremock/mappings/account-api.json")
.WithAutoRemove(true)
.Build();

RabbitMqContainer = new RabbitMqBuilder()
.WithImage("rabbitmq:3.11-alpine")
.WithAutoRemove(true)
.Build();
}

public PostgreSqlContainer PostgreSqlContainer { get; }

public WireMockContainer WireMockContainer { get; }

public RabbitMqContainer RabbitMqContainer { get; }

public Task DisposeAsync() => Task.WhenAll(
PostgreSqlContainer.DisposeAsync().AsTask(),
WireMockContainer.DisposeAsync().AsTask(),
RabbitMqContainer.DisposeAsync().AsTask());

public Task InitializeAsync() => Task.WhenAll(
PostgreSqlContainer.StartAsync(),
WireMockContainer.StartAsync(),
RabbitMqContainer.StartAsync());
}

Essa configuração prepara um container do PostgreSql com um database nomeado transactions, cria uma tabela chamada TRANSACTIONS e faz um seed de dados. Além disso, um container do WireMock com o mapeamento de um endpoint e contratos de request e response. Por fim, um container do RabbitMq sem nenhuma configuração adicional.

Com esse fixture criado, temos que fazer algumas alterações no arquivo CustomWebApplicationFactory.cs, como:

  1. Atribuir as configurações da aplicação (IConfiguration) com as conexões dos containers que serão criados
  2. Adicionar um ciclo de vida para subir os containers antes da execução dos testes e destrui-los após todos os testes terem sido executados.
  3. Criar um contexto unico para que todos os testes reutilizem.
// #CustomWebApplicationFactory.cs

public class CustomWebApplicationFactory
: WebApplicationFactory<Program>, IAsyncLifetime
{
private static readonly ContainersFixture _containersFixture = new();

public async Task InitializeAsync()
=> await _containersFixture.InitializeAsync(); 👈 // Sobe todos os containers antes dos testes

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, config) => config
.AddInMemoryCollection([ 👈 // Cria um IConfiguration com as configs dos containers que subiram
new KeyValuePair<string, string?>("ConnectionStrings:Database", _containersFixture.PostgreSqlContainer.GetConnectionString()),
new KeyValuePair<string, string?>("ConnectionStrings:RabbitMq", _containersFixture.RabbitMqContainer.GetConnectionString()),
new KeyValuePair<string, string?>("AccountApi:BaseUrl", _containersFixture.WireMockContainer.GetBaseUrl().ToString()),
new KeyValuePair<string, string?>("AccountApi:Timeout", "00:00:30"),
]));
builder.UseTestServer();
}

async Task IAsyncLifetime.DisposeAsync()
=> await _containersFixture.DisposeAsync(); 👈 // Destroi todos os containers depois dos testes
}

[CollectionDefinition(nameof(CustomWebApplicationFactoryCollection))]
public class CustomWebApplicationFactoryCollection : ICollectionFixture<CustomWebApplicationFactory>;

Note que temos uma classe adicional nesse arquivo

[CollectionDefinition(nameof(CustomWebApplicationFactoryCollection))]
public class CustomWebApplicationFactoryCollection : ICollectionFixture<CustomWebApplicationFactory>;

Essa classe define uma Collection Fixture, que significa que queremos criar um único contexto de teste e compartilhá-lo entre os testes em várias classes de teste, e limpá-lo depois que todos os testes tiverem sido concluídos.

Isso é ótimo para o cenário desse artigo, pois, queremos inicializar uma quantidade de containers e, em seguida, deixa-los rodando para serem usados por várias classes de teste. E criando essa classe decorada com o ICollectionFixture, compartilharmos uma única instância do CustomWebApplicationFactory entre os testes em várias classes de teste.

Por fim, temos que trocar o tipo de compartilhamento de contexto na classe base IntegrationTest, removendo o IClassFixture<CustomWebApplicationFactory> e adicionando o atributo Collection com o nome da collection que criamos.

// #IntegrationTest.cs

[Collection(name: nameof(CustomWebApplicationFactoryCollection))] 👈
public abstract class IntegrationTest(
CustomWebApplicationFactory factory,
ITestOutputHelper outputHelper)
{
...
}

Hora de testar novamente, podemos executar todos os testes de integração e ver o resultado com o seguinte comando:

> dotnet test -v quiet --filter "FullyQualifiedName~IntegrationTest"

Test run for /workspaces/integrationtests-testcontainer/tests/Transactions.Api.IntegrationTest/bin/Debug/net8.0/Transactions.Api.IntegrationTest.dll (.NETCoreApp,Version=v8.0)
Microsoft (R) Test Execution Command Line Tool Version 17.8.0 (x64)
Copyright (c) Microsoft Corporation. All rights reserved.

Microsoft (R) Test Execution Command Line Tool Version 17.8.0 (x64)
Copyright (c) Microsoft Corporation. All rights reserved.

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.

[testcontainers.org 00:00:00.05] Connected to Docker:
Host: unix:///var/run/docker.sock
Server Version: 24.0.7-1
Kernel Version: 6.5.0-14-generic
API Version: 1.43
Operating System: Ubuntu 22.04.3 LTS (containerized)
Total Memory: 13.56 GB
[testcontainers.org 00:00:00.22] Docker container 44734db96fa2 created
[testcontainers.org 00:00:00.28] Start Docker container 44734db96fa2
[testcontainers.org 00:00:00.52] Wait for Docker container 44734db96fa2 to complete readiness checks
[testcontainers.org 00:00:00.52] Docker container 44734db96fa2 ready
[testcontainers.org 00:00:00.56] Docker container 2115bcd58f6c created
[testcontainers.org 00:00:00.56] Docker container 6df5d5ab925a created
[testcontainers.org 00:00:00.56] Docker container c94f59cdb1f8 created
[testcontainers.org 00:00:00.56] Start Docker container 6df5d5ab925a
[testcontainers.org 00:00:00.57] Copy tar archive to "/" to Docker container c94f59cdb1f8
[testcontainers.org 00:00:00.57] Copy tar archive to "/" to Docker container 2115bcd58f6c
[testcontainers.org 00:00:00.57] Copy tar archive to "/" to Docker container 2115bcd58f6c
[testcontainers.org 00:00:00.58] Start Docker container c94f59cdb1f8
[testcontainers.org 00:00:00.59] Start Docker container 2115bcd58f6c
[testcontainers.org 00:00:00.87] Wait for Docker container c94f59cdb1f8 to complete readiness checks
[testcontainers.org 00:00:00.92] Wait for Docker container 2115bcd58f6c to complete readiness checks
[testcontainers.org 00:00:00.92] Wait for Docker container 6df5d5ab925a to complete readiness checks
[testcontainers.org 00:00:02.90] Docker container c94f59cdb1f8 ready --|
[testcontainers.org 00:00:03.05] Docker container 2115bcd58f6c ready | 👈 Containers criados e prontos para uso
[testcontainers.org 00:00:07.96] Docker container 6df5d5ab925a ready --|
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.LogicalHandler[100]
Start processing HTTP request GET http://172.18.0.1:32807/api/v1/accounts/7fb0f9ea-ded3-4747-bb9d-4fccfc1b8c36
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.ClientHandler[100]
Sending HTTP request GET http://172.18.0.1:32807/api/v1/accounts/7fb0f9ea-ded3-4747-bb9d-4fccfc1b8c36
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.ClientHandler[101]
Received HTTP response headers after 159.9885ms - 200
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.LogicalHandler[101]
End processing HTTP request after 170.5491ms - 200
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.LogicalHandler[100]
Start processing HTTP request GET http://172.18.0.1:32807/api/v1/accounts/d2da038f-c457-6e17-2784-556757ccbde5
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.ClientHandler[100]
Sending HTTP request GET http://172.18.0.1:32807/api/v1/accounts/d2da038f-c457-6e17-2784-556757ccbde5
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.ClientHandler[101]
Received HTTP response headers after 4.7831ms - 404
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.LogicalHandler[101]
End processing HTTP request after 4.8838ms - 404
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.LogicalHandler[100]
Start processing HTTP request GET http://172.18.0.1:32807/api/v1/accounts/7fb0f9ea-ded3-4747-bb9d-4fccfc1b8c36
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.ClientHandler[100]
Sending HTTP request GET http://172.18.0.1:32807/api/v1/accounts/7fb0f9ea-ded3-4747-bb9d-4fccfc1b8c36
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.ClientHandler[101]
Received HTTP response headers after 4.7485ms - 200
info: System.Net.Http.HttpClient.ICustomerAccountServiceClient.LogicalHandler[101]
End processing HTTP request after 4.8375ms - 200
[testcontainers.org 00:00:08.85] Delete Docker container c94f59cdb1f8 --|
[testcontainers.org 00:00:08.85] Delete Docker container 2115bcd58f6c | 👈 Containers deletados após os testes
[testcontainers.org 00:00:08.85] Delete Docker container 6df5d5ab925a --|

Passed! - Failed: 0, Passed: 6, Skipped: 0, Total: 6, Duration: 105 ms - Transactions.Api.IntegrationTest.dll (net8.0)

Sucesso! Todos os testes passaram e conseguimos criar uma ambiente de testes bem flexivel. O mais legal é que podemos acompanhar o ciclo de vida dos containers através dos logs no console.

Automatizando execução dos testes

Criando workflow no Github Actions

Primeira etapa é criar os diretórios onde os arquivos com as especificações de workflow ficam e criar um arquivo yml.

.github/
└── workflows
└── dotnet.yml

Especificação do arquivo dotnet.yml

name: Dotnet CI Workflow

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout source code
uses: actions/checkout@v3

- name: Install .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 8.0.x

- name: Restore cached nuget packages
id: cache-nuget-packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
restore-keys: |
${{ runner.os }}-nuget-

- name: Restore nuget packages
if: steps.cache-nuget-packages.outputs.cache-hit == false
run: dotnet restore --use-lock-file

- name: Build projects
run: dotnet build

- name: Run unit tests
run: dotnet test --no-build --filter "FullyQualifiedName!~IntegrationTest"

- name: Run integration tests
run: dotnet test --no-build --filter "FullyQualifiedName~IntegrationTest"

Agora, qualquer alteração que subir para esse repositório vai disparar esse workflow, que fará a execução automatizada dos testes do projeto. Assim, garantindo a qualidade sempre.

Conclusão

Entendemos a importância dos testes de integração e como implementarmos em ambientes .NET, destacando a eficácia da combinação entre Testcontainers e XUnit.

Além de como a utilização de Testcontainers simplifica a gestão de ambientes de teste, permitindo a criação e destruição de contêineres de maneira automatizada. Isso não apenas aumenta a eficiência do processo de teste, mas também proporciona ambientes mais realistas, resultando em uma cobertura mais abrangente.

--

--

Leonardo Guilen

Software Engineer focused on backend technologies, DevOps and Cloud. Researcher of the best coding standards, performance optimization and security enhancements