.NET C# Testes DevOps Backend PostgreSQL Docker

Testcontainers no .NET: testes de integração com banco e broker reais no CI/CD

Aprenda a usar Testcontainers para rodar PostgreSQL, RabbitMQ e Redis reais em testes de integração .NET — sem mocks frágeis, sem banco em memória.

N
Neryx Digital Architects
21 de fevereiro de 2026
12 min de leitura
200 profissionais leram
Categoria: Arquitetura Público: Times de plataforma e operação Etapa: Decisão

O grande problema dos testes de integração no .NET sempre foi o banco de dados. As opções clássicas têm trade-offs ruins: banco em memória (SQLite, InMemory EF Core) não reproduz o comportamento real do PostgreSQL — índices, triggers, transações, tipos específicos. Banco compartilhado em CI quebra quando múltiplos pipelines rodam em paralelo. Banco local exige setup manual e não funciona na máquina de outro dev sem configuração.

Testcontainers resolve isso de forma elegante: você declara no código quais containers precisa (PostgreSQL, RabbitMQ, Redis, Kafka) e a biblioteca os sobe via Docker, roda os testes e os destrói automaticamente. Zero configuração manual, zero banco compartilhado, funciona local e no CI.


Setup inicial

Instale os pacotes necessários:

dotnet add package Testcontainers
dotnet add package Testcontainers.PostgreSql
dotnet add package Testcontainers.RabbitMq
dotnet add package Testcontainers.Redis

Para os testes em si:

dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package xunit
dotnet add package xunit.runner.visualstudio

Requisito: Docker rodando na máquina de desenvolvimento e no agente de CI (GitHub Actions, GitLab CI, Azure Pipelines — todos suportam Docker-in-Docker ou Docker socket).


PostgreSQL container em testes

O caso mais comum: testar repositórios e queries EF Core com PostgreSQL real.

Container básico

public class OrderRepositoryTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithDatabase("orders_test")
        .WithUsername("test")
        .WithPassword("test")
        .Build();

    private AppDbContext _db = null!;

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(_postgres.GetConnectionString())
            .Options;

        _db = new AppDbContext(options);
        await _db.Database.MigrateAsync(); // aplica as migrations reais
    }

    public async Task DisposeAsync()
    {
        await _db.DisposeAsync();
        await _postgres.DisposeAsync();
    }

    [Fact]
    public async Task CreateOrder_ShouldPersistWithCorrectStatus()
    {
        // Arrange
        var order = Order.Create(customerId: Guid.NewGuid(), total: 299.90m);

        // Act
        _db.Orders.Add(order);
        await _db.SaveChangesAsync();

        // Assert — consulta o banco real, sem cache do DbContext
        var saved = await _db.Orders
            .AsNoTracking()
            .FirstOrDefaultAsync(o => o.Id == order.Id);

        Assert.NotNull(saved);
        Assert.Equal(OrderStatus.Pending, saved.Status);
        Assert.Equal(299.90m, saved.Total);
    }

    [Fact]
    public async Task GetActiveOrders_ShouldRespectGlobalQueryFilter()
    {
        // Testa se o soft-delete Global Query Filter funciona no PostgreSQL real
        var order = Order.Create(customerId: Guid.NewGuid(), total: 100m);
        order.SoftDelete();

        _db.Orders.Add(order);
        await _db.SaveChangesAsync();
        _db.ChangeTracker.Clear();

        var activeOrders = await _db.Orders.ToListAsync();

        Assert.DoesNotContain(activeOrders, o => o.Id == order.Id);
    }
}

O IAsyncLifetime do xUnit garante que o container sobe antes dos testes e desce depois — sem [OneTimeSetUp] ou [ClassInitialize] do MSTest.


Fixture compartilhada para testes da mesma classe

Subir um container por teste é lento. O padrão recomendado é uma fixture compartilhada por classe (ou coleção):

public class DatabaseFixture : IAsyncLifetime
{
    public PostgreSqlContainer Postgres { get; } = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithDatabase("testdb")
        .WithUsername("test")
        .WithPassword("test")
        .Build();

    public string ConnectionString => Postgres.GetConnectionString();

    public async Task InitializeAsync()
    {
        await Postgres.StartAsync();

        // Aplica migrations uma vez para toda a fixture
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(ConnectionString)
            .Options;

        await using var db = new AppDbContext(options);
        await db.Database.MigrateAsync();
    }

    public async Task DisposeAsync() => await Postgres.DisposeAsync();
}

// Classe de testes que reusa o container
public class OrderRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _fixture;

    public OrderRepositoryTests(DatabaseFixture fixture) => _fixture = fixture;

    private AppDbContext CreateDb()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(_fixture.ConnectionString)
            .Options;
        return new AppDbContext(options);
    }

    [Fact]
    public async Task Should_Create_And_Retrieve_Order()
    {
        await using var db = CreateDb();
        var order = Order.Create(Guid.NewGuid(), 150m);
        db.Orders.Add(order);
        await db.SaveChangesAsync();

        await using var db2 = CreateDb();
        var found = await db2.Orders.FindAsync(order.Id);
        Assert.NotNull(found);
    }
}

IClassFixture<DatabaseFixture>: o container sobe uma vez e é compartilhado por todos os testes da classe. Isso reduz o tempo total de execução de O(n) para O(1) containers por classe.


WebApplicationFactory com Testcontainers

O padrão mais poderoso: testar a API completa (endpoint → handler → repositório → banco) com o banco real, mas em memória de teste:

public class ApiIntegrationFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .Build();

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Remove o DbContext registrado na aplicação real
            var descriptor = services.SingleOrDefault(d =>
                d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            if (descriptor != null) services.Remove(descriptor);

            // Registra DbContext apontando para o container de teste
            services.AddDbContext<AppDbContext>(options =>
                options.UseNpgsql(_postgres.GetConnectionString()));

            // Aplica migrations
            using var sp = services.BuildServiceProvider();
            using var db = sp.GetRequiredService<AppDbContext>();
            db.Database.Migrate();
        });
    }

    public new async Task DisposeAsync()
    {
        await _postgres.DisposeAsync();
        await base.DisposeAsync();
    }
}

public class OrderEndpointsTests : IClassFixture<ApiIntegrationFixture>
{
    private readonly HttpClient _client;

    public OrderEndpointsTests(ApiIntegrationFixture factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task POST_order_ShouldReturn201_AndPersist()
    {
        var re

<p>####PUB4####</p>

quest = new { CustomerId = Guid.NewGuid(), Total = 199.90 };
        var response = await _client.PostAsJsonAsync("/orders", request);

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);

        var created = await response.Content.ReadFromJsonAsync<OrderResponse>();
        Assert.NotNull(created);
        Assert.Equal(199.90m, created.Total);

        // Verifica persistência buscando o recurso criado
        var get = await _client.GetAsync($"/orders/{created.Id}");
        Assert.Equal(HttpStatusCode.OK, get.StatusCode);
    }
}

Esses testes cobrem o fluxo completo — roteamento, validação, handler, EF Core, migrations, PostgreSQL — com o mínimo de mocks.


RabbitMQ container

Para testar publicação e consumo de mensagens (MassTransit, RawRabbitMQ):

public class RabbitMqFixture : IAsyncLifetime
{
    public RabbitMqContainer RabbitMq { get; } = new RabbitMqBuilder()
        .WithImage("rabbitmq:3.13-management-alpine")
        .Build();

    public string ConnectionString => RabbitMq.GetConnectionString();

    public Task InitializeAsync() => RabbitMq.StartAsync();
    public Task DisposeAsync() => RabbitMq.DisposeAsync().AsTask();
}

public class OrderCreatedConsumerTests : IClassFixture<RabbitMqFixture>
{
    private readonly RabbitMqFixture _fixture;

    public OrderCreatedConsumerTests(RabbitMqFixture fixture) => _fixture = fixture;

    [Fact]
    public async Task Consumer_ShouldProcess_OrderCreatedEvent()
    {
        // Configura MassTransit in-memory com o RabbitMQ real
        var services = new ServiceCollection();
        services.AddMassTransit(x =>
        {
            x.AddConsumer<OrderCreatedConsumer>();
            x.UsingRabbitMq((ctx, cfg) =>
            {
                cfg.Host(_fixture.ConnectionString);
                cfg.ConfigureEndpoints(ctx);
            });
        });

        await using var sp = services.BuildServiceProvider();
        var busControl = sp.GetRequiredService<IBusControl>();
        await busControl.StartAsync();

        try
        {
            var publishEndpoint = sp.GetRequiredService<IPublishEndpoint>();
            await publishEndpoint.Publish(new OrderCreatedEvent(Guid.NewGuid(), 299m));

            // Aguarda o processamento (em produção, use TestHarness)
            await Task.Delay(500);

            // Verifica efeito colateral do consumidor
            // ex: email enfileirado, banco atualizado, etc.
        }
        finally
        {
            await busControl.StopAsync();
        }
    }
}

Para MassTransit especificamente, o InMemoryTestHarness é ainda melhor para testes unitários de consumidores. O RabbitMQ container brilha nos testes end-to-end onde você quer garantir que a configuração de exchange/queue está correta.


Redis container

public class CacheServiceTests : IAsyncLifetime
{
    private readonly RedisContainer _redis = new RedisBuilder()
        .WithImage("redis:7-alpine")
        .Build();

    private IDistributedCache _cache = null!;

    public async Task InitializeAsync()
    {
        await _redis.StartAsync();

        var services = new ServiceCollection();
        services.AddStackExchangeRedisCache(options =>
        {
            options.Configuration = _redis.GetConnectionString();
        });

        var sp = services.BuildServiceProvider();
        _cache = sp.GetRequiredService<IDistributedCache>();
    }

    public Task DisposeAsync() => _redis.DisposeAsync().AsTask();

    [Fact]
    public async Task Cache_ShouldExpire_AfterTtl()
    {
        await _cache.SetStringAsync("key", "value", new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(200)
        });

        var immediate = await _cache.GetStringAsync("key");
        Assert.Equal("value", immediate);

        await Task.Delay(300);

        var expired = await _cache.GetStringAsync("key");
        Assert.Null(expired); // Redis real expira de verdade
    }
}

Com InMemory, a expiração de cache pode ter comportamento diferente. O Redis container garante que o TTL funciona como em produção.


Coleções de testes (paralelismo controlado)

Quando múltiplas classes de teste precisam do mesmo container, use [Collection] do xUnit para compartilhar a fixture sem criar containers duplicados:

[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }

[Collection("Database")]
public class OrderRepositoryTests
{
    public OrderRepositoryTests(DatabaseFixture fixture) { ... }
}

[Collection("Database")]
public class CustomerRepositoryTests
{
    public CustomerRepositoryTests(DatabaseFixture fixture) { ... }
}

O xUnit sobe o container uma única vez e compartilha entre as duas classes. Classes em coleções diferentes rodam em paralelo — cada uma com seu próprio container.


CI/CD com GitHub Actions

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'

      - name: Run tests
        run: dotnet test --configuration Release --logger trx
        # Docker está disponível por padrão no ubuntu-latest
        # Testcontainers encontra o socket /var/run/docker.sock automaticamente

O runner ubuntu-latest já tem o Docker instalado e o socket disponível. Nenhuma configuração adicional é necessária para Testcontainers funcionar no GitHub Actions. O mesmo vale para GitLab CI com services: docker:dind e Azure Pipelines com agentes Linux.


Banco em memória vs. Testcontainers

CritérioEF Core InMemory / SQLiteTestcontainers PostgreSQL
Velocidade de startupImediata5–15s (pull na primeira vez)
Comportamento real do banco❌ Diferente✅ Idêntico à produção
Migrations reais❌ Ignora✅ Aplica e valida
Índices e constraints❌ Sem suporte real✅ Funciona
Tipos PostgreSQL (jsonb, uuid, enum)
Paralelismo (múltiplos CIs)✅ Sem conflito✅ Cada container isolado
Custo de infraestruturaNenhumNenhum (Docker local)

A recomendação prática: use InMemory para testes unitários de lógica de negócio que não dependem de SQL real. Use Testcontainers para qualquer teste que valide queries, índices, migrations ou comportamento específico do banco.


Resumo

Testcontainers elimina a principal desculpa para não ter testes de integração confiáveis no .NET:

  • IClassFixture compartilha o container por classe de testes — startup único, custo fixo.
  • [Collection] compartilha entre classes — ainda mais eficiente para suítes grandes.
  • WebApplicationFactory + container testa a API completa do endpoint ao banco real.
  • Migrations aplicadas no teste garantem que o schema de produção é o mesmo testado.
  • GitHub Actions: zero configuração extra — o Docker socket está disponível por padrão.

Se os seus testes de integração usam SQLite in-memory porque “é mais simples”, você provavelmente tem bugs que só aparecem em produção. Testcontainers custa meia dúzia de linhas a mais e elimina essa categoria inteira de problemas.

Quer sair do modo reativo e priorizar o que mais importa?

O diagnóstico de maturidade ajuda a transformar sintomas operacionais em um plano mais claro de evolução.

Avaliar maturidade

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.