1

92% de cobertura numa base herdada: contei as asserções antes de acreditar no número

Recebi acesso de leitura a um monolito em Node com TypeScript, algo perto de 200 mil linhas, seis anos de histórico e quatro fornecedores diferentes no caminho. A primeira informação que me passaram sobre qualidade foi uma linha: "a cobertura está em 92%".

Cobertura mede linha executada durante a suíte. Ela não mede verificação. Um teste que chama a função, não explode e termina conta a linha como coberta exatamente igual a um teste que compara o resultado com o esperado. Antes de abrir qualquer arquivo de produção, rodei um contador de asserções.

Primeira passada: por arquivo, com regex

Comecei pelo mais burro possível, porque roda em qualquer repositório sem instalar nada.

// conta-arquivos.js
const { readFileSync } = require('node:fs');
const { execSync } = require('node:child_process');

const files = execSync(
  "find . -path ./node_modules -prune -o \\( -name '*.spec.ts' -o -name '*.test.ts' \\) -print",
  { encoding: 'utf8' }
).trim().split('\n').filter(Boolean);

const ASSERCAO = /\b(expect|assert|should|toMatchSnapshot)\s*\(/;
const CASO = /\b(it|test)\s*\(/g;

let casos = 0;
const mudos = [];

for (const file of files) {
  const src = readFileSync(file, 'utf8');
  casos += (src.match(CASO) || []).length;
  if (!ASSERCAO.test(src)) mudos.push(file);
}

console.log(`arquivos de teste: ${files.length}`);
console.log(`casos (it/test): ${casos}`);
console.log(`arquivos sem nenhuma asserção: ${mudos.length}`);
mudos.slice(0, 5).forEach((f) => console.log('  ' + f));

Saída:

$ node conta-arquivos.js
arquivos de teste: 214
casos (it/test): 1318
arquivos sem nenhuma asserção: 37
  src/billing/invoice.reprocess.spec.ts
  src/integrations/erp.sync.spec.ts
  src/reports/closing.spec.ts
  src/queue/retry-policy.spec.ts
  src/auth/session.cleanup.spec.ts

37 arquivos subiam módulo, chamavam função, esperavam promessa e não verificavam nada. Eles passam sempre. E cada linha que eles tocam entra na conta dos 92%.

Segunda passada: por caso, com AST

O número por arquivo é otimista, porque um arquivo com uma asserção no primeiro caso já sai da lista. O que importa é o caso, e aí a regex empaca. Troquei por ts-morph.

// conta-casos.js
const { Project, SyntaxKind } = require('ts-morph');

const project = new Project({ tsConfigFilePath: './tsconfig.json' });
const alvos = new Set(['it', 'test', 'it.only', 'test.only']);

let total = 0;
const mudos = [];

for (const sf of project.getSourceFiles(['**/*.spec.ts', '**/*.test.ts'])) {
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
    if (!alvos.has(call.getExpression().getText())) continue;
    total++;

    const corpo = call.getArguments()[1];
    if (!corpo) continue;

    const verifica = corpo
      .getDescendantsOfKind(SyntaxKind.CallExpression)
      .some((c) => /^(expect|assert|chai\.expect)/.test(c.getExpression().getText()));

    if (!verifica) {
      mudos.push(`${sf.getBaseName()} :: ${call.getArguments()[0].getText()}`);
    }
  }
}

const pct = ((mudos.length / total) * 100).toFixed(1);
console.log(`casos: ${total}`);
console.log(`casos sem asserção: ${mudos.length} (${pct}%)`);
mudos.slice(0, 5).forEach((m) => console.log('  ' + m));
$ node conta-casos.js
casos: 1318
casos sem asserção: 241 (18.3%)
  invoice.reprocess.spec.ts :: 'reprocessa fatura vencida'
  erp.sync.spec.ts :: 'sincroniza pedidos do dia'
  closing.spec.ts :: 'gera fechamento mensal'
  retry-policy.spec.ts :: 'respeita o backoff'
  session.cleanup.spec.ts :: 'limpa sessões expiradas'

Quase um em cada cinco casos não faz pergunta nenhuma ao sistema. Repare nos nomes: são justamente os fluxos que mexem em dinheiro e em integração externa. Ninguém escreve teste mudo para o formatador de CPF, porque ali a asserção é óbvia. O teste mudo nasce onde montar o cenário já deu trabalho demais e a pessoa parou quando o vermelho sumiu.

Um exemplar típico:

it('reprocessa fatura vencida', async () => {
  const fatura = await criarFatura({ status: 'vencida' });
  await service.reprocessar(fatura.id);
});

Esse caso percorre o serviço de faturamento inteiro, marca todas as linhas como cobertas e só falha se alguém jogar uma exceção não tratada. Troque a regra de juros por outra e ele continua verde.

O primo disso no monitoramento

O mesmo padrão aparece em observabilidade, e nessa base ele estava lá:

@Get('/health')
health() {
  return { status: 'ok', uptime: process.uptime() };
}

O endpoint responde 200 porque foi programado para responder 200. O painel fica verde com o banco fora do ar. A métrica existe, a garantia não. Substituí por uma versão que toca a dependência com timeout curto e devolve 503 quando ela não responde, que é uma das poucas mudanças que eu aceito fazer antes de entender o sistema.

O que eu faço com esse inventário

Nada de refatorar, e vou explicar por quê. Os 241 casos mudos são, ao mesmo tempo, o mapa das áreas que ninguém consegue testar direito e a lista do que está sem rede de proteção. Mexer neles antes de medir é trocar de lugar um problema que você ainda não conhece.

O que entra na primeira rodada é aditivo:

  1. Congelar os arquivos de produção que aparecem nos casos mudos e são também os mais alterados no último ano. É onde a regra de negócio vive.
  2. Instrumentar em vez de mexer: log estruturado nas rotas que esses fluxos atendem, captura de erro ligada, alarme no job noturno que falha calado.
  3. Rodar mutation testing num diretório só, não na suíte inteira. Em src/billing o Stryker levou perto de 40 minutos e devolveu um score que não tinha relação nenhuma com os 92%. Na suíte completa levaria a madrugada e ninguém esperaria.
  4. Escrever asserção primeiro nos casos mudos que já existem, aproveitando o cenário montado. É o teste mais barato de escrever que existe, porque o trabalho chato já está feito.

O teste do mutante é o único que responde à pergunta que interessa: se eu mudar o sinal daquela comparação, alguma coisa fica vermelha? Cobertura e contagem de asserção são degraus antes dele, e servem porque rodam em minutos.

Onde isso não serve

Não serve se o sistema está caindo agora. Com o checkout fora do ar, você estanca primeiro e mede depois, aceitando alguns dias às cegas. Também rende pouco em base pequena, coisa de 8 ou 10 mil linhas, onde ler os testes inteiros leva menos tempo que montar o instrumento.

E o script tem limite honesto: ele não enxerga asserção dentro de helper próprio. Se o time escreveu verificaFatura(resultado) com os expects lá dentro, o caso entra na lista de mudos sem ser mudo. Nessa base eu conferi na mão os 37 arquivos do primeiro passo e três eram falso positivo.

Rodei nos dois últimos sistemas que herdei e a faixa ficou entre 12% e 20% de casos sem asserção. Queria saber se isso é o normal do mercado ou azar meu. Se você rodar aí, comenta o número: quantos por cento dos seus casos não verificam nada, e o score de mutação bateu com a cobertura quando você foi conferir?

· · ·

Publicado originalmente no blog da Revin: https://revin.com.br/pt/blog/codigo-espaguete-herdado-7-primeiros-dias

Carregando publicação patrocinada...