Programação Por Equipe DevBras

Capa do artigo: Cobertura de 100% sem assert: como saber se o teste verifica alguma coisa

Cobertura de 100% sem assert: como saber se o teste verifica alguma coisa

Entenda o que a cobertura mede, ache testes que executam sem afirmar nada e use mutação, manual ou com ferramenta, para medir se a suíte pega bug.

A meta do trimestre era 90% de cobertura, e o painel bateu 94%. Na semana seguinte, o desconto de cliente VIP passou a sair em dobro e nenhum teste falhou. Alguém abriu o arquivo e achou o motivo: o teste chamava calcularDesconto com quatro combinações de entrada e não conferia nenhum resultado. A resposta curta: cobertura mede que linhas foram executadas, não que o efeito foi verificado. Use o número para achar código que nenhum teste toca, olhe as asserções para saber o que foi de fato verificado, e faça o teste de mutação: troque o corpo da função por um retorno fixo e veja se algum teste falha.

Se nada falhar com a função quebrada de propósito, aquela parte do código não tem teste, tenha o número que tiver.

O que a cobertura mede e o que ela não vê

A ferramenta de cobertura instrumenta o código e registra cada linha ou ramo que rodou durante a suíte. No fim, divide o que rodou pelo total. É uma medida de execução. Ela não sabe se, depois de rodar a linha, alguém olhou o resultado.

Por isso existem dois tipos de buraco. O primeiro é o que a cobertura mostra bem: código que nenhum teste executa. Esse relatório é útil, porque aponta arquivos esquecidos e ramos de erro nunca exercitados. O segundo é o que ela não mostra: código executado sem verificação. Um teste sem asserção, uma asserção fraca demais ou uma promessa que ninguém espera contam como cobertura completa.

Há ainda a diferença entre cobertura de linha e de ramo. Um if numa linha só conta como coberto pela linha mesmo que o teste só passe pelo lado verdadeiro. A cobertura de ramo (branch coverage) mostra o lado que ficou de fora, e por isso costuma ser mais honesta.

O exemplo: o desconto que ninguém conferia

// src/desconto.js
function calcularDesconto(totalCentavos, cliente) {
  if (cliente.vip && totalCentavos >= 20000) return Math.round(totalCentavos * 0.10);
  if (cliente.vip) return Math.round(totalCentavos * 0.05);
  if (totalCentavos >= 50000) return 2500;
  return 0;
}

module.exports = { calcularDesconto };

E o teste que deixava o painel verde:

test('calcula desconto', () => {
  calcularDesconto(30000, { vip: true });
  calcularDesconto(10000, { vip: true });
  calcularDesconto(60000, { vip: false });
  calcularDesconto(1000, { vip: false });
});

As quatro chamadas passam por todas as linhas e todos os ramos. Cobertura de 100% no arquivo. E o teste continua passando se a função devolver o dobro, zero ou um número negativo, porque ele não compara nada com nada.

Como transformar cobertura em confiança

  1. Gere o relatório com cobertura de ramo. No Jest, use --coverage; no executor nativo do Node, node --test --experimental-test-coverage; a ferramenta c8 também funciona com qualquer executor. Olhe os ramos não cobertos, não só a porcentagem total.
  2. Procure testes sem asserção. Uma busca por arquivos de teste que não contêm assert ou expect já encontra os piores casos. Regras de lint, como as do plugin de Jest para ESLint, também acusam testes sem expect.
  3. Escreva a afirmação antes da chamada. Comece o teste pelo resultado esperado: "dado VIP com R$ 300, o desconto é R$ 30". A chamada vem depois para cumprir a frase.
  4. Cubra as fronteiras de cada regra. R$ 199,99 e R$ 200 para o VIP, R$ 499,99 e R$ 500 para o desconto fixo.
  5. Faça a mutação manual. Troque o corpo por return 0, rode os testes e confira que pelo menos um falha. Depois troque >= por > e repita.
  6. Automatize a mutação nas regras críticas. Ferramentas de teste de mutação fazem essas trocas sozinhas e contam quantas sobreviveram.

O teste reescrito

// test/desconto.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const { calcularDesconto } = require('../src/desconto');

test('VIP a partir de R$ 200 ganha 10%', () => {
  assert.equal(calcularDesconto(20000, { vip: true }), 2000);
});

test('VIP abaixo de R$ 200 ganha 5%', () => {
  assert.equal(calcularDesconto(19999, { vip: true }), 1000);
});

test('não VIP a partir de R$ 500 ganha R$ 25 fixos', () => {
  assert.equal(calcularDesconto(50000, { vip: false }), 2500);
});

test('não VIP abaixo de R$ 500 não ganha desconto', () => {
  assert.equal(calcularDesconto(49999, { vip: false }), 0);
});

A cobertura continua em 100%. A diferença é que agora qualquer mudança no resultado derruba um teste. O caso de 19.999 centavos confere o arredondamento: 5% dá 999,95, que Math.round leva a 1.000.

Mutação com ferramenta

O teste de mutação gera versões levemente erradas do código, os mutantes, e roda a suíte contra cada uma. Se a suíte falha, o mutante "morreu" e o teste fez o trabalho. Se passa, o mutante "sobreviveu" e mostra exatamente qual mudança nenhum teste percebe. Para JavaScript e TypeScript existe o Stryker, com variantes para C# (Stryker.NET) e Scala; em Java, o PIT; em Python, o mutmut.

npx stryker init
npx stryker run

Rodar mutação na base inteira é lento. Comece pelos módulos que mexem com dinheiro, permissão e prazo, onde um bug custa caro, e trate o relatório como lista de testes a escrever.

Onde a meta de cobertura atrapalha

Quando a porcentagem vira meta que bloqueia o merge, as pessoas otimizam para a porcentagem. O jeito mais barato de subir o número é chamar a função sem conferir, e é exatamente isso que aparece em suítes com meta agressiva. Um limite mínimo pode servir de alarme contra queda brusca, mas não diz nada sobre a qualidade dos testes que estão acima dele.

Use a cobertura como mapa. Ela responde "onde não há teste nenhum?". A mutação e a leitura das asserções respondem "os testes que existem pegam bug?".

Erros comuns

  • Teste sem asserção. Conta como cobertura total e só falha se o código lançar exceção.
  • Promessa não aguardada. Sem await ou return, o teste termina antes da asserção rodar, e a falha some ou aparece em outro teste.
  • Asserção que sempre passa. Conferir só que o resultado "existe" ou não é nulo aceita qualquer número errado.
  • Exceção engolida no teste. Um try com catch vazio em volta da asserção transforma falha em sucesso.
  • Snapshot atualizado sem leitura. Rodar a atualização de snapshot toda vez que quebra faz o teste aprovar qualquer saída nova.
  • Meta de porcentagem como único critério de release. Incentiva testes vazios e dá falsa segurança para quem lê o painel.

Checklist

  • O relatório de cobertura inclui ramos, não só linhas.
  • Nenhum arquivo de teste está sem asserção.
  • Cada regra de negócio tem casos nas duas pontas de cada limite.
  • Trocar o corpo de uma função crítica por um retorno fixo derruba pelo menos um teste.
  • Testes assíncronos aguardam a promessa antes de terminar.
  • Módulos de dinheiro e permissão passam por teste de mutação de tempos em tempos.
  • A porcentagem de cobertura é lida junto com os mutantes sobreviventes.

Escolha a função que calcula dinheiro no seu sistema, troque o corpo dela por return 0 e rode a suíte: quantos testes ficam vermelhos?

Avalie este artigo

Referência: https://martinfowler.com/bliki/TestCoverage.html