Assinar
Voltar
Engenharia

Como testar Vertical Slice Architecture sem herdar a pirâmide de testes

Você migrou para vertical slice mas manteve a estratégia de testes da arquitetura em camadas. O resultado são trinta testes verdes que não pegariam a migration esquecida. A slice é a unidade de teste.

{}

Você migrou para Vertical Slice Architecture. Cada funcionalidade virou uma pasta autocontida: o endpoint, o validador, o handler, a consulta ao banco, tudo junto. O código ficou muito mais fácil de navegar — abrir Features/Pedidos/CriarPedido e ver a feature inteira em um arquivo é libertador depois de anos caçando implementação em cinco camadas.

Aí chega a hora de testar, e o time faz o de sempre: mocka o repositório, instancia o handler, verifica se o método foi chamado. Trinta testes verdes. E o primeiro bug em produção é uma migration esquecida que nenhum deles pegaria.

O problema não é a arquitetura. É que a estratégia de testes ficou na arquitetura anterior.

Por que a pirâmide de testes atrita com vertical slice

A pirâmide clássica — muita unidade, alguma integração, pouquíssimo end-to-end — nasceu para arquiteturas em camadas. E fazia todo sentido lá: em um sistema com controller, service, repository e domínio, cada camada é uma unidade com fronteira clara. Testar a camada isolada, com as vizinhas mockadas, é barato e diz alguma coisa.

Vertical slice desmonta essa premissa. A slice é fina de propósito: ela existe justamente para não ter camadas intermediárias. Um handler típico valida a entrada, roda uma regra, grava e devolve. Se você mocka tudo em volta, o que sobra para testar é o mapeamento de DTO.

Pior: os mocks te acorrentam à implementação. Você trocou MediatR por chamada direta? Trinta testes quebram. Trocou EF Core por Dapper naquela consulta lenta? Quebram de novo. Eles não estão testando comportamento, estão testando a fiação interna — exatamente o que a slice deveria deixar você mudar livremente.

Se o teste quebra quando você refatora sem mudar comportamento, ele não é uma rede de segurança. É um imposto.

A inversão: a slice é a unidade de teste

A mudança de mentalidade cabe em uma frase: uma requisição entra por cima, um resultado observável sai por baixo.

Você não testa “o handler”. Você testa a slice inteira, de ponta a ponta, pela sua fronteira pública. E “resultado observável” significa três coisas concretas, nessa ordem de importância:

  • A resposta HTTP. Status code, corpo, cabeçalhos. É o contrato com quem consome.
  • O estado do banco. A linha foi criada, com os valores certos? O saldo foi debitado?
  • As mensagens emitidas. O evento foi para a fila, o outbox recebeu o registro?

Repare no que ficou de fora: como chegou lá. O teste não sabe se existe MediatR no meio, se a persistência é EF ou Dapper, se o validador é FluentValidation ou um if. Isso é a propriedade que você quer — refatoração interna sem tocar em teste.

Como isso fica na prática

Em .NET, a combinação é WebApplicationFactory para subir a aplicação de verdade e Testcontainers para subir um banco de verdade em Docker:

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

    public async Task InitializeAsync()
    {
        await _db.StartAsync();
        // aplica as migrations no banco recém-criado
        await MigrarAsync(_db.GetConnectionString());
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(o =>
                o.UseNpgsql(_db.GetConnectionString()));

            // só o que é externo de verdade vira fake
            services.RemoveAll<IGatewayPagamento>();
            services.AddSingleton<IGatewayPagamento, GatewayPagamentoFake>();
        });
    }

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

E o teste em si fica curto o suficiente para caber na cabeça:

[Fact]
public async Task Criar_pedido_persiste_e_devolve_201()
{
    var client = _fixture.CreateClient();

    var resposta = await client.PostAsJsonAsync("/pedidos",
        new { ClienteId = _clienteId, Valor = 250.00m });

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

    // valida pelo identificador do caso, não por contagem de linhas
    var pedido = await _db.Pedidos.SingleAsync(p => p.ClienteId == _clienteId);
    Assert.Equal(250.00m, pedido.Valor);
    Assert.Equal(StatusPedido.Aberto, pedido.Status);
}

Um detalhe pequeno com consequência grande: a asserção busca SingleAsync(p => p.ClienteId == _clienteId) em vez de Count() == 1. Verificar contagem global assume que o banco está vazio, o que amarra o teste à ordem de execução e proíbe paralelismo. Buscar pelo identificador do próprio caso torna o teste indiferente a qualquer outro rodando junto.

O que continua sendo teste de unidade

Isso não é um manifesto contra teste unitário. É sobre apontá-lo para onde ele rende.

Teste unitário é caro em manutenção e barato em execução; teste de integração é o inverso. Então vale unidade onde há muitos caminhos por pouca infraestrutura: cálculo de imposto com sete faixas, máquina de estados de um pedido, política de reajuste com regras de arredondamento, parser de um formato legado.

Essa lógica não deveria estar dentro do handler de qualquer forma. Extraia para o domínio — uma classe ou função pura, sem dependência de banco ou HTTP — e cubra com um [Theory] de vinte casos. Roda em milissegundos, documenta a regra melhor que qualquer comentário, e não quebra quando você troca o ORM.

O oposto também vale: handler fino não merece teste unitário. Se ele só valida, chama uma coisa e devolve, o teste de integração já cobriu tudo que havia para cobrir. Um unitário ali só duplica cobertura e adiciona um mock para manter.

O teste que protege a arquitetura

Existe uma categoria de erro que nenhum teste funcional pega: a erosão da própria arquitetura. É quase sempre a mesma história — alguém precisa de uma função que já existe na slice vizinha, importa direto de Features/Pedidos para dentro de Features/Faturamento, e o PR passa porque tudo continua verde.

Seis meses depois, a promessa da vertical slice acabou: nenhuma feature pode mais ser alterada ou deletada sem efeito colateral, e você tem um monolito em camadas com nomes de pasta bonitos.

A defesa é um teste de arquitetura — com ArchUnitNET, NetArchTest ou uma regra própria de análise — que afirma o invariante em código:

[Fact]
public void Slices_nao_referenciam_outras_slices()
{
    var slices = Types.InAssembly(typeof(Program).Assembly)
        .That().ResideInNamespaceMatching(@"App\.Features\.(\w+)");

    var resultado = slices
        .ShouldNot().HaveDependencyOnOtherSlices()  // regra própria
        .GetResult();

    Assert.True(resultado.IsSuccessful, string.Join("\n", resultado.FailingTypeNames));
}

Compartilhamento continua permitido — só que explicitamente, por um projeto Shared ou pelo domínio. A diferença é que agora mover código para lá é uma decisão consciente, revisada em PR, e não um using que ninguém reparou.

O preço: velocidade, e como pagar menos

A objeção previsível é que teste de integração é lento. É verdade, e ignorar isso é como o time acaba desligando a suíte. Três medidas resolvem a maior parte:

  • Um contêiner por suíte, não por teste. Subir Postgres custa alguns segundos; fazer isso trezentas vezes custa a tarde. Compartilhe a fixture com ICollectionFixture e pague o preço uma vez.
  • Limpe o estado, não recrie o banco. Ferramentas como Respawn truncam as tabelas entre testes em milissegundos, contra dezenas de segundos de derrubar e reaplicar migrations.
  • Isole por dado, não por transação. Se cada teste usa seus próprios identificadores — cliente novo, pedido novo — eles não colidem, e você pode rodar em paralelo. É a otimização de maior retorno da lista.

Com isso, uma suíte de duzentos testes de slice roda em um ou dois minutos. É mais que os oito segundos da suíte só de mocks. Mas é a única das duas que teria pego a migration esquecida.

O resumo em quatro linhas

  1. A slice é a unidade. Teste pela fronteira: requisição entra, resultado observável sai.
  2. Integração é a base, não o topo. Banco real em contêiner; falsifique só o que é externo e cobra caro — gateway de pagamento, provedor de e-mail, API de terceiro.
  3. Unidade para lógica de domínio densa. Extraída da slice, pura, com muitos casos.
  4. Um teste de arquitetura impedindo que slices se referenciem — é o único que protege a decisão que te trouxe até aqui.

A pergunta que resolve qualquer caso duvidoso é sempre a mesma: este teste quebraria se eu mudasse a implementação sem mudar o comportamento? Se a resposta for sim, ele está testando a coisa errada.

Perguntas frequentes

Isso não é só chamar tudo de “teste de integração”?

A diferença está no escopo e no que se afirma. Um teste de integração tradicional exercita a colaboração entre dois componentes; aqui o alvo é a fatia vertical completa, verificada pelo contrato externo. O nome importa menos que a fronteira escolhida.

E se eu não puder usar Docker no CI?

Tente antes de descartar — quase todo runner moderno suporta. Se realmente não der, um banco compartilhado no CI com schema por execução funciona. O que não funciona é banco em memória fingindo ser Postgres: ele não reproduz constraint, tipo, colação nem comportamento transacional, e o teste passa a mentir exatamente sobre o que você queria verificar.

Qual cobertura eu devo buscar?

Cobertura por linha é a métrica errada aqui. Vale mais garantir que cada slice tem pelo menos um teste de caminho feliz e um de erro relevante — validação recusada, recurso inexistente, conflito de concorrência. Isso cobre o que quebra em produção melhor que perseguir um número.

Vale testar por HTTP ou chamar o handler direto?

Por HTTP. É mais lento em milissegundos e cobre roteamento, model binding, filtros, middleware de autenticação e serialização — a faixa onde mora uma parcela desproporcional dos bugs reais.

Fontes

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *