12k
All articles

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.

OpenReplay Team
OpenReplay Team
Verificações automatizadas que reduzem a carga da revisão de código

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çãoO que detectaO que o revisor não precisa mais verificarFerramenta
Geração de código a partir do contratoCampos que a API nunca retornaSe cada campo da resposta existeHey API openapi-ts
Limites entre camadasImports que atravessam camadas arquiteturaisSe cada import está na direção corretadependency-cruiser
Teste de mutaçãoTestes que executam código sem fazer asserções sobre eleSe cada novo teste realmente verifica o comportamentoStryker
Código mortoArquivos, exports e dependências não utilizadosSe algo ficou órfãoKnip
DuplicaçãoBlocos copiados e coladosSe essa lógica já existejscpd

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 forbidden do dependency-cruiser com severidade error reprova o CI quando um service importa um componente. Execute-o com o reporter err-long e o comment da 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.break do 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:

  1. Adicione cada nova regra como aviso. No dependency-cruiser, severity: "warn" reporta as violações, mas não reprova o job. Somente error faz isso.
  2. Execute as verificações apenas nos arquivos alterados.
  3. Promova um aviso a erro assim que ele detectar um bug real e exclua as regras que só geram ruído.
  4. 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.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.