Verificações automatizadas que reduzem a carga da revisão de código
Automatize revisões de código com OpenAPI, dependency-cruiser e testes de mutação do Stryker. Configure verificações de CI para detectar falhas e reduzir o trabalho de revisão.
A revisão de código automatizada, em sua forma determinística, é um conjunto de verificações de CI que reprovam um pull request quando o código viola uma regra. Assim, os revisores dedicam seu tempo ao design, à intenção e à adequação ao produto, e não a erros mecânicos.
Os agentes de código hoje abrem pull requests mais rápido do que qualquer pessoa consegue lê-los linha por linha. Se você é tech lead, acaba aprovando diffs no automático ou virando o gargalo, e nenhuma das duas situações se sustenta.
A solução é deixar que as máquinas detectem os problemas mecânicos e reservar a atenção humana para todo o resto. Vale a pena executar essas verificações independentemente de quem escreveu o código. Este artigo aborda três delas, acrescenta dois extras de baixo custo, mostra como integrar tudo ao CI e termina com o que nenhuma delas consegue detectar. Você provavelmente já executa lint, formatação e testes unitários. Se não executa, comece por esta configuração de ESLint e Prettier.
| Verificação | O que detecta | O que o revisor não precisa mais verificar | Ferramenta |
|---|---|---|---|
| Geração de código a partir do contrato | Campos que a API nunca retorna | Se cada campo da resposta existe | Hey API openapi-ts |
| Limites entre camadas | Imports que atravessam camadas arquiteturais | Se cada import está na direção correta | dependency-cruiser |
| Teste de mutação | Testes que executam código sem fazer asserções sobre ele | Se cada novo teste realmente verifica o comportamento | Stryker |
| Código morto | Arquivos, exports e dependências não utilizados | Se algo ficou órfão | Knip |
| Duplicação | Blocos copiados e colados | Se essa lógica já existe | jscpd |
Principais conclusões
- Se o cliente TypeScript é gerado a partir de uma especificação OpenAPI, ler um campo que a API nunca retorna, como
user.fullName, gera um erro de compilação em vez de um comentário de revisão. - Uma regra
forbiddendo dependency-cruiser com severidadeerrorreprova o CI quando um service importa um componente. Execute-o com o reportererr-longe ocommentda regra aparecerá ao lado de cada violação, indicando ao desenvolvedor para onde o import deve ir. - A cobertura de linhas mostra que o código foi executado. O teste de mutação mostra se algum teste falha quando esse código é quebrado de propósito.
- Trate o mutation score como um detector e deixe o
thresholds.breakdo Stryker sem definir, porque mutantes equivalentes tornam um limite rígido enganoso. - Introduza novas regras como avisos nos arquivos alterados. Promova a erro as que detectarem bugs reais e torne um job obrigatório somente depois que ele estiver estável.
Gere seu cliente de API a partir da especificação OpenAPI
Se o cliente TypeScript é gerado a partir de uma especificação OpenAPI, ler um campo que a API nunca retorna, como user.fullName, gera um erro de compilação em vez de um comentário de revisão. Uma interface escrita à mão pode se desalinhar do backend sem que ninguém perceba. Uma interface gerada, não.
Um tipo escrito à mão compila mesmo quando está errado:
// Hand-written: compiles, but the backend never sends fullName
interface User { id: string; fullName: string }
declare const user: User;
const label = user.fullName;
Gere o tipo a partir da especificação e a mesma leitura falhará em tempo de compilação:
// Generated from the spec
type User = { id: string; firstName: string; lastName: string };
declare const user: User;
const label = user.fullName;
// error TS2339: Property 'fullName' does not exist on type 'User'.
const fixed = `${user.firstName} ${user.lastName}`;
Ferramentas como o Hey API openapi-ts leem um documento OpenAPI e produzem tipos e um cliente. Depois que o cliente é gerado, revise a especificação. Trate o código gerado como artefato de build: não o edite manualmente nem o revise linha por linha.
Os tipos desaparecem em tempo de compilação, então adicione um validador em tempo de execução, como o Zod, para as respostas em que você não pode se dar ao luxo de confiar. Nada disso funciona sem uma especificação oficial que o backend mantenha atualizada. A configuração completa é abordada em como criar clientes de API type-safe com OpenAPI e TypeScript.
Imponha limites entre camadas com o dependency-cruiser
O dependency-cruiser verifica cada import de um projeto JavaScript ou TypeScript com base nas regras forbidden do seu arquivo de configuração. As violações são exibidas em um formato bem parecido com a saída do ESLint, e qualquer violação de uma regra definida como error faz o comando terminar com código diferente de zero, o que reprova o job de CI. Isso tira da lista do revisor a pergunta “esse service deveria mesmo importar um componente?”.
npm install --save-dev dependency-cruiser
npx dependency-cruiser --init
O --init gera o .dependency-cruiser.js, ou o .dependency-cruiser.cjs quando o package.json define "type": "module". O arquivo criado já vem com um conjunto de regras padrão. Elas sinalizam ciclos de import, módulos órfãos, pacotes que você importa mas nunca declarou no package.json e código de produção que puxa dependências de desenvolvimento. Adicione sua regra de camadas junto a elas:
module.exports = {
forbidden: [
{
name: "services-not-to-components",
comment:
"Services must not import UI. Move shared types to src/types or next to the service.",
severity: "error",
from: { path: "^src/services" },
to: { path: "^src/components" },
},
],
};
Escreva o comment como uma instrução, não como um rótulo. O reporter padrão err o omite, mas o reporter err-long o exibe em cada violação. Assim, o desenvolvedor ou agente que provocou a falha lê a correção, e não apenas o erro:
npx dependency-cruiser --output-type err-long src
O eslint-plugin-boundaries faz o mesmo trabalho dentro do ESLint, caso você prefira manter isso no seu linter. O oxlint não tem uma regra nativa para camadas arquiteturais, mas sua regra no-restricted-imports consegue bloquear imports que correspondam a um padrão.
Use teste de mutação para verificar se os testes detectam falhas
O teste de mutação avalia a qualidade dos testes: a cobertura de linhas mostra que o código foi executado, enquanto o teste de mutação mostra se algum teste falha quando esse código é alterado de propósito. Uma suíte pode atingir 100% de cobertura e ainda assim quase não fazer asserções, e é com o teste de mutação que você descobre isso.
O Stryker faz pequenas alterações no seu código-fonte, e cada versão alterada é chamada de mutante. Em seguida, ele executa seus testes contra cada mutante. Um mutante é morto quando algum teste falha contra ele, e sobrevive quando a suíte inteira continua passando.
O próprio exemplo do Stryker muta return user.age >= 18 de quatro formas: > 18, < 18, return false e return true.
export function isUserOldEnough(user: { age: number }): boolean {
return user.age >= 18;
}
// Weak: 100% line coverage, two survivors
it("lets adults in", () => {
expect(isUserOldEnough({ age: 25 })).toBe(true);
});
// Added: boundary cases
it("lets in someone who is exactly 18", () => {
expect(isUserOldEnough({ age: 18 })).toBe(true);
});
it("keeps out someone who is 17", () => {
expect(isUserOldEnough({ age: 17 })).toBe(false);
});
Um teste que verifica apenas uma pessoa de 25 anos passa tanto contra o mutante > 18 quanto contra o mutante return true, então ambos sobrevivem, mesmo com 100% de cobertura de linhas. Esse teste mata apenas < 18 e return false. O caso de 18 anos mata > 18, e o caso de 17 anos mata return true. Com os três testes, todos os mutantes são mortos.
Mutantes sobreviventes geralmente vêm de alguns padrões reconhecíveis de testes fracos:
- Verificar que uma função foi chamada sem verificar com quais argumentos.
- Verificar que um resultado existe, ou qual é o seu tamanho, sem olhar o que ele contém.
- Commitar um snapshot que ninguém conferiu com a saída esperada.
- Calcular o valor esperado com a mesma lógica do código sob teste.
- Verificar o que um mock retorna em vez do que o código faz com esse retorno.
Um mutante sobrevivente aponta para um comportamento que nenhum teste garante. Alguns mutantes, porém, são equivalentes ao código original, então o mutation score funciona melhor como detector do que como critério de aprovação/reprovação. Um limite rígido também recompensa testes escritos para matar mutantes, e não para verificar comportamento. O thresholds.break do Stryker reprova o build abaixo de uma pontuação definida. Seu padrão é null, e é assim que você deve deixá-lo. Em vez disso, leia os sobreviventes no PR.
Para manter as execuções curtas, mute apenas os arquivos alterados pelo PR com npx stryker run --mutate <comma-separated files>. O modo incremental (--incremental) guarda os resultados da última execução em reports/stryker-incremental.json e reaproveita o que for possível, mas no CI isso só economiza tempo se o arquivo for mantido em cache entre as execuções. Instale também o plugin de test runner do seu framework (@stryker-mutator/vitest-runner ou @stryker-mutator/jest-runner). Sem ele, o Stryker recorre ao command runner, que executa a suíte inteira para cada mutante. Para o lado de julgamento da qualidade dos testes, ou seja, decidir o que merece um teste, veja nosso artigo sobre testes na era da IA.
Execute cada verificação como um job de CI obrigatório e separado
Uma verificação que pode ser ignorada não está sendo imposta. Dê a cada verificação seu próprio job, para que uma falha fique evidente à primeira vista, e depois marque cada job como status check obrigatório na sua proteção de branch ou ruleset. Se o GitHub Actions é novidade para você, comece por este guia prático de GitHub Actions.
on: pull_request
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx eslint . && npx prettier --check .
- run: npx dependency-cruiser --output-type err-long src
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx tsc --noEmit
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm test
mutation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- name: Mutate changed files only
run: |
FILES=$(git diff --name-only --diff-filter=AMR "origin/${{ github.base_ref }}...HEAD" -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx' \
| grep -vE '\.(test|spec)\.tsx?$' | paste -sd, -)
if [ -z "$FILES" ]; then echo "No source changes"; exit 0; fi
npx stryker run --mutate "$FILES"
build:
needs: [lint, typecheck, test, mutation]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build
O job de mutação busca o histórico completo (fetch-depth: 0) para conseguir fazer o diff com a branch base. Em seguida, passa ao Stryker apenas os arquivos-fonte alterados, deixando os arquivos de teste de fora.
Introduza novas regras de forma gradual:
- Adicione cada nova regra como aviso. No dependency-cruiser,
severity: "warn"reporta as violações, mas não reprova o job. Somenteerrorfaz isso. - Execute as verificações apenas nos arquivos alterados.
- Promova um aviso a erro assim que ele detectar um bug real e exclua as regras que só geram ruído.
- Torne um job obrigatório somente depois que ele estiver estável.
Vale a pena adicionar mais duas verificações automatizadas de baixo custo: o Knip para código morto e o jscpd para duplicação. O Knip reporta arquivos que nada importa, exports que ninguém usa e pacotes de que você não precisa mais. O jscpd sinaliza blocos de código duplicados.
O que as verificações automatizadas de revisão de código não conseguem dizer?
Verificações determinísticas não conseguem dizer se a funcionalidade é a certa, se uma abstração foi criada cedo demais ou se alguém no time entende o código. Essas são questões de julgamento e exigem uma pessoa.
Um pull request que passa em todas as verificações determinísticas só comprovou estar livre dos defeitos que essas verificações procuram. Para o lado humano da revisão, veja nosso artigo sobre testes na era da IA.
Comece pelo contrato
Cada questão mecânica que você delega a uma máquina devolve à revisão humana tempo para as questões que só pessoas conseguem responder. Se for adicionar apenas uma verificação nesta sprint, gere seu cliente de API a partir da especificação, porque isso elimina uma classe inteira de defeitos antes que alguém os escreva. Em seguida, adicione teste de mutação no diff do PR, como um job que apenas emite avisos, e leia o que sobrevive.
Perguntas frequentes
Como adicionar regras do dependency-cruiser a uma base de código existente que já tem centenas de violações?
Registre as violações existentes como uma baseline e reprove apenas as novas. A partir do dependency-cruiser 18.3.0, a forma recomendada é a opção --baseline. Por padrão, ela grava o arquivo .dependency-cruiser-known-violations.json na raiz do projeto, e executar com --ignore-known rebaixa para ignore todas as violações listadas ali. Qualquer novo import que viole a mesma regra continua reprovando. Como a correspondência ignora a severidade, alterar a severidade de uma regra mantém suas entradas na baseline.
O teste de mutação do Stryker funciona com o Vitest?
Sim. O StrykerJS adicionou suporte ao Vitest na versão 7.0 por meio do plugin @stryker-mutator/vitest-runner, e projetos Jest usam o @stryker-mutator/jest-runner. O runner do Vitest sempre usa a análise de cobertura perTest e ignora qualquer configuração de coverageAnalysis que você definir. O Vitest Browser Mode não é suportado. O Stryker executa os testes em uma única thread por worker e, em vez disso, paraleliza entre seus próprios workers.
O que é um mutante equivalente no teste de mutação?
Um mutante equivalente altera o código-fonte sem mudar o que o programa faz, de modo que nenhum teste jamais consegue matá-lo. Um exemplo clássico é substituir i < length por i != length em um loop que conta de um em um a partir de zero: ambos param no mesmo ponto. Como mutantes equivalentes sempre sobrevivem, um mutation score de 100% costuma ser inatingível, e os sobreviventes precisam ser lidos por uma pessoa.
O que acontece quando o Stryker é executado em arquivos alterados que não têm testes?
Por padrão, o Stryker para com o erro No tests found quando a execução inicial de dry run não executa nenhum teste, o que reprova um job de CI que muta apenas o diff do PR. A partir do StrykerJS 7.0, a flag --allowEmpty (ou allowEmpty: true no arquivo de configuração) impede que isso seja tratado como erro. Mutantes em código que nenhum teste executa são reportados como sem cobertura, o que aponta diretamente para os testes que estão faltando.