← Todos os artigos

A pirâmide de testes te ensinou a medir a coisa errada

Dá pra ter 87% de cobertura, a proporção perfeita entre unitário e E2E, e ainda assim entregar um relatório de custo com sete dígitos a mais. Já vi. Ajudei a construir o gate que deixou passar.

Quinta-feira, 16h40. O relatório de custo por hectare da safra 2024/25 abre mostrando R$ 42.414.238,11 por hectare em um talhão de soja de 84,5 ha. O número certo era R$ 4.278,34. O cliente não abriu chamado: ligou. Eu passei tempo demais do outro lado desse telefone pra achar que isso é detalhe.

A suíte estava verde. Cobertura em 87%. E o módulo tinha o desenho de livro: uma base larga de unitário, uma faixa de integração, uns poucos E2E no topo. Formato de pirâmide impecável.

Nenhum daqueles testes tinha a menor chance de pegar aquele bug.

A pirâmide nunca foi o problema

O Mike Cohn desenhou aquilo em 2009, no Succeeding with Agile, e o argumento dele era econômico, não estético. Teste unitário roda em milissegundos, não precisa de banco, não depende de rede, falha de um jeito que aponta pro arquivo. Teste de ponta a ponta roda em minutos, precisa de ambiente, depende de terceiro e falha de um jeito que só diz "algo deu errado em algum lugar". A conclusão natural é: escreva mais do que é barato e rápido, escreva menos do que é caro e lento.

Isso está certo. Continua certo em 2026. O custo marginal de um teste unitário a mais é praticamente zero e o custo marginal de um E2E a mais é real — CPU no runner, tempo de fila, alguém pra investigar quando ficar vermelho sem motivo.

O que aconteceu é que uma heurística de custo virou uma métrica de quantidade. E aí o negócio desandou.

70/20/10 é uma proporção sem unidade

Pergunta que eu faço quando alguém me mostra o gráfico de pizza da suíte: 70% de quê? Setenta por cento dos testes que existem. E os testes que existem são os que alguém escreveu. A métrica está medindo o comportamento passado do time, não o risco do produto.

Um teste que valida um getter e um teste que valida o rateio de custo indireto entre talhões contam igual no denominador. Se a sua métrica trata os dois como a mesma coisa, ela não está medindo nada que importe.

Na prática, a leitura popular da pirâmide fez três estragos. Virou meta de painel — "estamos com 62% de unitário, precisamos subir". Virou álibi pra não escrever E2E — "a pirâmide diz que E2E é topo, então cinco está bom". E virou permissão pra encher a base de coisa sem risco nenhum: mapper de DTO, construtor, getter, o método que só repassa a chamada pro repositório.

E aqui eu preciso confessar uma coisa: o gate de 80% de cobertura no Jenkins era meu. Eu escrevi, eu defendi na reunião, eu argumentei que era pra criar cultura. O que criou foi uma geração de testes de mapper. O pessoal precisava passar no gate pra fazer merge, e testar mapper é o jeito mais barato de subir percentual. A regra era minha e era ruim.

Quanto mais mock, mais o teste vira espelho do código

Volta pro bug das 16h40. Esse é o serviço, simplificado só o suficiente pra caber na tela:

src/safra/custo-safra.service.ts
@Injectable()
export class CustoSafraService {
  constructor(
    private readonly lancamentos: LancamentoRepository,
    private readonly talhoes: TalhaoRepository,
  ) {}

  async custoPorHectare(safraId: string): Promise<LinhaCusto[]> {
    const talhoes = await this.talhoes.listarPorSafra(safraId);
    const somas = await this.lancamentos.somarPorTalhao(safraId);

    return talhoes.map((talhao) => {
      const insumos = somas.find((s) => s.talhaoId === talhao.id)?.total ?? 0;

      // custo indireto já vem rateado da apuração mensal
      const custoTotal = insumos + talhao.custoIndiretoRateado;

      return {
        talhaoId: talhao.id,
        custoHectare: Number((custoTotal / talhao.areaHa).toFixed(2)),
      };
    });
  }
}

E esse é o teste unitário que ficou verde durante os quatro meses inteiros em que o bug esteve em produção:

src/safra/custo-safra.service.spec.ts
describe('CustoSafraService', () => {
  const lancamentos = { somarPorTalhao: jest.fn() };
  const talhoes = { listarPorSafra: jest.fn() };

  const service = new CustoSafraService(
    lancamentos as any,
    talhoes as any,
  );

  it('calcula o custo por hectare do talhão', async () => {
    talhoes.listarPorSafra.mockResolvedValue([
      { id: 'TLH-19', areaHa: 84.5, custoIndiretoRateado: 3120 },
    ]);

    lancamentos.somarPorTalhao.mockResolvedValue([
      // number, porque eu escrevi o mock e eu achei que era number
      { talhaoId: 'TLH-19', total: 358400 },
    ]);

    const [linha] = await service.custoPorHectare('SFR-2425');

    expect(linha.custoHectare).toBe(4278.34);
    expect(lancamentos.somarPorTalhao).toHaveBeenCalledWith('SFR-2425');
  });
});

O repositório de verdade faz SUM(valor) numa coluna numeric. O driver pg devolve numeric como string, de propósito, pra não perder precisão em valores maiores que o que um float aguenta. Então total não é 358400. É '358400'.

E em JavaScript, '358400' + 3120 é '3584003120'.

Divide por 84,5 e você tem os R$ 42.414.238,11 que apareceram na tela do cliente. Uma linha. Um operador. Quatro meses.

O teste unitário não pegou porque ele não estava testando o sistema. Estava testando a minha ideia do sistema. Eu escrevi o mock a partir da assinatura em TypeScript, e a assinatura em TypeScript era uma mentira — number no tipo, string no runtime, porque tipo em TS não sobrevive à fronteira do driver. O mock reproduziu fielmente o meu erro de compreensão e depois validou que eu tinha entendido tudo certo.

Na prática

A regra que eu uso hoje: se o mock de um teste foi escrito lendo o tipo em vez de lendo o dado que sai do sistema real, esse teste não protege a integração. Ele protege a sua leitura do tipo. Nas bordas — banco, fila, HTTP de terceiro, arquivo — o teste tem que tocar a coisa de verdade pelo menos uma vez.

O teste que pega, e que custa 40 vezes mais tempo de execução do que o unitário (o que ainda é barato):

test/custo-safra.integration-spec.ts
describe('CustoSafraService — Postgres real', () => {
  let service: CustoSafraService;
  let db: DataSource;

  beforeAll(async () => {
    const mod = await Test.createTestingModule({
      imports: [TypeOrmModule.forRoot(configPostgresEfemero), SafraModule],
    }).compile();

    service = mod.get(CustoSafraService);
    db = mod.get(DataSource);
  });

  beforeEach(() => semearTalhaoComLancamentos(db, {
    safraId: 'SFR-2425',
    talhaoId: 'TLH-19',
    areaHa: 84.5,
    custoIndiretoRateado: 3120,
    lancamentos: [210000, 98400, 50000],   // soma 358.400
  }));

  it('não concatena a soma do banco com o custo indireto', async () => {
    const [linha] = await service.custoPorHectare('SFR-2425');

    expect(linha.custoHectare).toBe(4278.34);
  });

  afterAll(() => db.destroy());
});

Repara que o teste de integração não é mais inteligente. Ele é mais burro, na verdade — não sabe nada sobre a implementação, só planta dado e confere número. É exatamente por isso que ele funciona.

O Testing Trophy resolve metade

O Kent C. Dodds propôs o Testing Trophy como resposta a isso, e a frase que ficou famosa dele é sobre escrever testes, não muitos, principalmente de integração. A parte gorda do troféu é integração justamente porque é ali que mora a relação custo/confiança melhor: você exercita mais código de verdade por teste escrito.

Eu concordo com boa parte. Mas vou ser honesto sobre onde o troféu também quebra, porque trocar um dogma por outro não é evolução, é rotação.

Primeiro: "integração" é a palavra mais elástica do vocabulário de teste. Já vi time chamar de integração o teste que sobe o módulo NestJS inteiro com o repositório mockado — o mesmo teste unitário de antes, só que com boot mais lento. Se você não define qual fronteira o teste cruza, o troféu vira rótulo.

Segundo: o troféu nasceu no contexto de front-end React, onde a maior parte do código é composição e a lógica pura é fina. Em backend de gestão agro, não é. Cálculo de rateio, conversão de saca pra tonelada, juros de barter, quebra de umidade — isso é matemática pura, determinística, com dezenas de casos de borda. Testar isso via integração é lento e não cobre melhor. É o território onde a base larga da pirâmide continua sendo a resposta certa.

Terceiro, e vale pros dois: troféu também é um desenho. Se você deixar, ele vira percentual em painel do mesmo jeito.

Cobertura de 100% não significa nada, e dá pra provar

Cobertura mede execução. Só isso. Ela responde "essa linha rodou durante a suíte?" — e não responde "alguém verificou o que ela produziu?".

Tem três níveis e a diferença importa. Cobertura de linha pergunta se a linha executou. Cobertura de branch pergunta se cada saída de cada condicional foi exercitada. Numa linha como if (safra.encerrada && talhao.ativo), um único teste com os dois valores verdadeiros te dá 100% de linha e 50% de branch — o caminho falso nunca rodou. Cobertura de caminho pergunta pelas combinações de branches ao longo da função, e ela explode: cada condicional independente dobra o número de caminhos. Uma função com 8 if tem 256 caminhos possíveis. Ninguém cobre isso, e o relatório do Jest nem tenta te contar.

Mas o furo maior não é nenhum desses. É que nenhum desses níveis exige asserção. Olha:

src/safra/rateio.ts
export function ratearPorArea(
  custoTotal: number,
  talhoes: Talhao[],
): Rateio[] {
  const areaTotal = talhoes.reduce((soma, t) => soma + t.areaHa, 0);

  return talhoes.map((t) => ({
    talhaoId: t.id,
    valor: Number(((custoTotal * t.areaHa) / areaTotal).toFixed(2)),
  }));
}
src/safra/rateio.spec.ts
it('rateia o custo entre os talhões da safra', () => {
  const rateio = ratearPorArea(10000, [
    { id: 'TLH-01', areaHa: 40 },
    { id: 'TLH-02', areaHa: 40 },
    { id: 'TLH-03', areaHa: 40 },
  ]);

  expect(rateio).toBeDefined();
  expect(rateio).toHaveLength(3);
});

Statements 100%. Branches 100% — não existe branch. Functions 100%. Lines 100%. Barra verde no relatório, o gate passa, o PR faz merge.

E a função está errada de dois jeitos. R$ 10.000 divididos entre três áreas iguais dá 3.333,33 por talhão, e 3333.33 × 3 = 9999.99. Sobra um centavo que nunca chega em talhão nenhum, e no fechamento contábil da safra esse centavo é uma divergência que alguém do financeiro vai perseguir por duas horas. Além disso, se todos os talhões estiverem em pousio com área zero, areaTotal é 0, a divisão dá NaN, e Number(NaN.toFixed(2)) é NaN — que o TypeScript aceita alegremente como number e o Postgres rejeita na hora do insert, à meia-noite, dentro do job de fechamento.

A asserção que valia alguma coisa cabe em uma linha e não muda a cobertura em nada:

expect(rateio.reduce((s, r) => s + r.valor, 0)).toBe(10000);

Cobertura é um bom detector de código não testado. É um péssimo certificado de código testado. Use como alarme de zona morta, nunca como meta.

Troca a forma da pirâmide por um mapa de risco

A pergunta "que formato tem a minha pirâmide?" não tem resposta útil. Essa aqui tem: qual a chance de isso quebrar, e qual o estrago se quebrar?

O jeito que eu monto isso hoje leva umas duas horas com o time e não precisa de ferramenta nenhuma.

Começa listando os fluxos que, se quebrarem, geram ligação pro suporte no mesmo dia. Não "no próximo sprint", não "quando alguém reparar". No mesmo dia. Esse filtro é brutal e é ótimo — de 40 telas de um ERP agro, sobram umas seis.

Depois vem a probabilidade, e é aqui que a maioria dos times chuta quando não precisava. Código que muda quebra. Código que não muda há um ano não quebra sozinho. git log é métrica de teste:

# quais arquivos mais mudaram nos últimos 6 meses
git log --since="6 months ago" --name-only --pretty=format: -- src/ \
  | grep '\.ts$' \
  | sort | uniq -c | sort -rn | head -20

Cruza essa lista com a lista de fluxos críticos. O que aparece nas duas é onde o seu teste tem que estar, independente de isso deixar a pirâmide gorda no meio, no topo ou de cabeça pra baixo.

Fluxo Commits / 6 meses Se quebrar Onde o teste tem que morar
Fechamento de safra 47 Número errado no resultado contábil; retrabalho de fechamento inteiro; ligação no mesmo dia Integração com Postgres real + 1 E2E do fluxo completo, do lançamento ao relatório
Custo por hectare 31 Decisão de plantio tomada em cima de número errado; o dano aparece na safra seguinte Unitário denso na regra pura (arredondamento, unidade, pousio) + integração no seam do repositório
Emissão de nota fiscal 12 Caminhão parado no pátio sem NF-e; para a operação física, não só o software Teste de contrato contra retorno gravado da SEFAZ + E2E em homologação, agendado, fora do PR
Cadastro de talhão 3 Campo obrigatório recusado; o usuário contorna e reclama depois Um teste de integração no controller. E só. Não precisa de mais nada.
Exportação de relatório em PDF 1 Layout torto; ninguém liga pro suporte por causa disso Nenhum teste automatizado. Revisão visual quando mexer.
O que muda de verdade

Repara que a última linha é a mais difícil de defender numa reunião e é a mais valiosa. Decidir não testar algo, por escrito, com justificativa, é o que transforma estratégia de teste em decisão de engenharia. Sem isso você só tem uma lista de desejos com percentual em cima.

"Mas E2E é caro, lento e instável — é por isso que a pirâmide existe"

Esse é o contra-argumento sério, e eu vou dar razão em parte antes de discordar.

Onde quem critica está certo: E2E tem uma classe de instabilidade que não é culpa de quem escreveu. Ambiente de homologação de terceiro cai. Certificado digital vence sem avisar. O relógio do container está 4 segundos adiantado e a validação de data recusa o lançamento. Isso existe e nenhuma técnica de escrita resolve. E, mais importante: o ciclo de feedback importa de verdade. Um teste que só te avisa 20 minutos depois do push muda o comportamento do desenvolvedor, e muda pra pior. Ele vai embora, volta, já esqueceu o contexto.

Onde eu discordo: quase toda a lentidão que eu já vi em suíte E2E era problema de engenharia de pipeline, não de estratégia de teste. A nossa suíte Cypress rodava em 41 minutos, e não tinha nada a ver com E2E ser E2E. Era um cy.wait(3000) fixo espalhado em 60 e poucos lugares, specs compartilhando o mesmo usuário de teste e por isso incapazes de rodar em paralelo, e um único container fazendo tudo em série. Trocar espera fixa por espera em condição, dar a cada spec o próprio tenant semeado via API, e distribuir em 6 containers no Jenkins levou pra 6 minutos. Nenhum teste foi apagado.

"E2E é lento" quase sempre significa "eu paralelizo zero e espero com sleep". Isso é um problema resolvido há uma década, e é mais barato de resolver do que a maioria dos times imagina.

Agora a concessão honesta, porque ela é real: suíte E2E sem dono apodrece. Se não existe alguém cujo trabalho inclui olhar pro relatório vermelho toda manhã, em três meses o time normaliza a falha, começa a dar retry automático em tudo e a suíte vira um ritual que ninguém lê. Nesse cenário, quem defende ter só cinco E2E está errado na teoria e certo na prática. Se você não tem quem cuide, não escreva — escrever teste que ninguém mantém é pior do que não ter, porque produz confiança falsa e ainda cobra pedágio no tempo de build.

O que eu faço agora

Eu não olho mais a distribuição da suíte. Olho duas coisas: a lista de fluxos que fazem o telefone tocar, e a lista de arquivos que mais mudaram. O que está nas duas eu testo até doer, no nível que exercita a fronteira que realmente pode quebrar. O que não está em nenhuma, eu deixo sem teste e escrevo o porquê no PR.

A pirâmide te diz quanto cada teste custa. Isso é útil, e o Cohn tinha razão. Só que ela nunca prometeu te dizer o que você tem a perder — essa parte cada time inventou sozinho, colocou num painel e passou dez anos otimizando.

Cobertura é o número que a gente mede quando não sabe do que tem medo.

Luciano Martins

Luciano Martins da Silva Junior

Bacharel em Engenharia de Software, pós-graduado em Ciência de Dados e Machine Learning. 8+ anos entre suporte, automação de testes e engenharia de dados. Escreve sobre o que quebra em produção.

Tem um problema difícil de qualidade?

Suíte instável, pipeline lento, dado que mente. É esse tipo de coisa que eu gosto de desembaraçar.

Vamos conversar