Testcontainers com .NET: testes que conversam com coisas de verdade
Testar contra um banco real sempre esbarrou no mesmo dilema: instalar e manter uma instância local só para teste é incômodo, e mockar o banco inteiro joga fora justamente o tipo de bug que só aparece com SQL de verdade rodando contra um motor de banco de verdade. Testcontainers resolve isso subindo a infraestrutura real dentro de um container, só durante a execução dos testes, e descartando tudo no final.
O que Testcontainers faz, sem retórica
Testcontainers é uma biblioteca que gerencia o ciclo de vida de containers Docker diretamente a partir do código de teste: sobe o container antes dos testes rodarem, expõe a porta necessária para conexão, e derruba o container assim que os testes terminam, sem deixar nenhum estado residual no ambiente onde os testes rodaram.
public class PedidoRepositoryTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.WithDatabase("testedb")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
public Task InitializeAsync() => _postgres.StartAsync();
public Task DisposeAsync() => _postgres.DisposeAsync().AsTask();
[Fact]
public async Task Inserir_DevePersistirPedido()
{
await using var connection = new NpgsqlConnection(_postgres.GetConnectionString());
var repository = new PedidoRepository(connection);
await repository.InserirAsync(new Pedido { ClienteId = 1 });
var pedidos = await repository.ListarPorClienteAsync(1);
Assert.Single(pedidos);
}
}
IAsyncLifetime, do xUnit, garante que o container suba antes do primeiro teste da classe e seja descartado depois do último, sem exigir gerenciamento manual de setup e teardown espalhado em cada método de teste.
Um módulo por tecnologia, mesma lógica por trás
O pacote principal, Testcontainers, fornece a infraestrutura genérica de gerenciamento de container. Módulos específicos, como Testcontainers.PostgreSql, Testcontainers.Redis e Testcontainers.LocalStack, adicionam builders convenientes já configurados para cada tecnologia, poupando de montar manualmente a configuração de porta, variável de ambiente e imagem correta para cada uma. Testcontainers.LocalStack em particular é útil para simular serviços AWS (S3, SQS, DynamoDB) localmente, sem precisar de conta AWS real nem depender de conectividade de rede durante o teste.
Isolamento de verdade, sem sujeira acumulada
Uma das vantagens práticas mais subestimadas é o isolamento entre execuções: cada rodada de teste sobe um container limpo, sem dado residual de uma execução anterior contaminando o resultado da próxima. Isso resolve um problema clássico de banco de teste compartilhado, onde um teste que não limpou direito seu próprio dado acaba interferindo silenciosamente no resultado de outro teste completamente sem relação, rodado depois.
O ambiente local de quem desenvolve também sai limpo depois: como o container só existe durante a execução dos testes, não sobra instância de banco rodando em segundo plano, ocupando porta ou consumindo recurso depois que os testes terminam.
O custo é tempo de execução, não complexidade de código
O principal trade-off de Testcontainers é velocidade: subir um container real, mesmo leve, é mais lento do que instanciar um mock em memória. Para uma suíte grande, isso pode significar minutos a mais de execução, o que vale a pena ponderar entre rodar essa suíte em todo commit local versus reservar para o pipeline de integração contínua, onde esse tempo adicional pesa menos no fluxo do dia a dia de quem está desenvolvendo.
Onde isso substitui mock, e onde não substitui
Testcontainers resolve bem o caso de testar a interação real com banco de dados, cache ou serviço de fila, coisas que se comportam de forma sutilmente diferente do que qualquer mock consegue simular fielmente (comportamento de transação, restrição de chave estrangeira, serialização real de tipo de dado). Não substitui a necessidade de testes unitários rápidos para lógica de negócio isolada, que continuam fazendo sentido sem precisar de container nenhum por trás. A combinação das duas abordagens, cada uma no lugar certo, tende a cobrir mais do sistema do que insistir só numa das duas.