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:
- 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.
- Instrumentar em vez de mexer: log estruturado nas rotas que esses fluxos atendem, captura de erro ligada, alarme no job noturno que falha calado.
- Rodar mutation testing num diretório só, não na suíte inteira. Em
src/billingo 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. - 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