12k
All articles

Testando na Era da IA: Usando IA para Escrever Testes

Testes gerados por IA podem aumentar a cobertura sem achar bugs. Use prompts baseados em requisitos, testes de mutação e revisão de mocks.

OpenReplay Team
OpenReplay Team
Testando na Era da IA: Usando IA para Escrever Testes

Um teste cujo valor esperado foi produzido pela execução do próprio código sob teste só pode confirmar o que o código já faz; se o bug já está lá, o teste o consolida.

Se você gera testes com um assistente, o padrão pode ser familiar: o PR adiciona quarenta testes, a cobertura sobe dois pontos, o CI está verde e o mesmo bug de faturamento continua chegando à produção na quinta-feira. Os testes nunca tiveram como discordar do código.

Este artigo aborda os três formatos que os testes gerados assumem quando passam sem proteger nada, e as três verificações que os corrigem: fazer o prompt a partir do requisito em vez da implementação, quebrar o código de propósito e ler os mocks antes das asserções.

Principais Conclusões

  • A cobertura de linhas registra quais linhas foram executadas e nada registra sobre se uma asserção falharia caso essas linhas produzissem a resposta errada.
  • Um valor esperado calculado à mão e um copiado da saída da função parecem idênticos em um diff; apenas sua procedência difere, e é por isso que testes gerados passam na revisão.
  • Pedir a um modelo que escreva testes para o nome de uma função lhe dá apenas a implementação como base; fornecer as regras de negócio lhe dá uma fonte da verdade capaz de discordar do código.
  • A verificação mais rápida de uma suíte gerada é alterar uma constante ou inverter uma comparação no código-fonte e executar novamente; se tudo permanecer verde, nada estava protegendo aquela linha.
  • Se um teste substitui por um mock a própria função que alega testar, sua asserção verifica o valor de retorno configurado no mock, e o teste deveria ser excluído em vez de reparado.

Por Que a Cobertura Sobe Enquanto os Bugs Continuam Chegando à Produção?

A cobertura mede execução, não verificação. Os provedores de cobertura do Jest (Babel/Istanbul por padrão, V8 opcional) e os provedores de cobertura do Vitest (V8 por padrão, Istanbul opcional) reportam as mesmas quatro métricas: statements, branches, functions e lines. Nenhum deles avalia se alguma asserção poderia falhar. Um teste que chama uma função e faz asserção sobre o que quer que tenha retornado cobre todas as linhas que tocou, exatamente como faria um teste com uma asserção correta.

É por isso que o número continua subindo enquanto os bugs continuam chegando à produção. Testes gerados são baratos, então mais código passa a ser executado sob teste. Mas um teste derivado da implementação compartilha todos os defeitos com a implementação, e o relatório de cobertura não tem coluna para isso.

Falha Um: O Teste Afirma O Que o Código Já Retorna

Um modelo que recebe apenas o código-fonte tem um único oráculo para valores esperados: o código-fonte. Ele raciocina sobre (ou executa) a função, observa a saída e escreve essa saída como literal na asserção. Se a função está errada, o literal está errado do mesmo jeito.

Considere uma função de desconto com um bug de copiar e colar:

// discount.ts
export function calculateDiscount(price: number, tier: "silver" | "gold"): number {
  const rate = tier === "gold" ? 0.25 : 0.25; // bug: silver should be 0.15
  return price * (1 - rate);
}

A API describe/it/expect abaixo funciona tanto no Jest quanto no Vitest; o Jest a expõe globalmente, enquanto o Vitest exige um import ou globals: true.

import { calculateDiscount } from "./discount";

// Generated from the implementation. 75 is what the buggy function returned.
it("applies silver discount", () => {
  expect(calculateDiscount(100, "silver")).toBe(75);
});

// Written from the rule: silver is 15% off, so 100 * 0.85.
it("charges 85 for a 100 silver order", () => {
  expect(calculateDiscount(100, "silver")).toBe(85);
});

O primeiro teste passa contra a função com bug. O segundo falha, e é exatamente esse o ponto. Em um diff, 75 e 85 parecem igualmente legítimos. Nada na sintaxe diz a um revisor que um foi calculado a partir da regra de precificação e o outro foi copiado da saída da função. A única defesa é perguntar de onde veio o número.

Falha Dois: Testes Gerados Cobrem Apenas o Caminho Feliz

Uma suíte gerada testa o que o prompt descreveu, e um prompt que apenas nomeia uma função descreve somente sua operação normal. Os casos que uma suíte gerada mais frequentemente omite (entrada malformada, uma dependência inacessível, um timeout) são justamente os que o prompt nunca mencionou, então o modelo não teve motivo para escrevê-los.

O resultado são cinco testes quase idênticos para entradas bem formadas com tiers válidos e nenhum para preço negativo, uma string de tier desconhecida ou um serviço upstream que nunca responde. Esses são os caminhos que chegam à produção sem testes, porque também são os caminhos que os desenvolvedores menos exercitam manualmente.

Falha Três: A Unidade Sob Teste É Mockada

Se um teste substitui por um mock a própria função que alega testar, sua asserção verifica o valor de retorno configurado do mock em vez do comportamento da função. Modelos mockam agressivamente porque mockar faz os testes passarem de forma confiável, e um teste que mocka o banco de dados, o cliente de rede e o serviço sob teste vai passar com qualquer implementação.

O padrão fica mais fácil de ver com uma dependência injetada, o que mantém o exemplo livre das diferenças entre jest.mock e vi.mock:

// cart.ts
import type { calculateDiscount } from "./discount";

export function cartTotal(price: number, tier: "silver" | "gold",
                          discount: typeof calculateDiscount): number {
  return Math.round(discount(price, tier) * 100) / 100;
}

// cart.test.ts
import { cartTotal } from "./cart";

// Before: the fake is the thing being checked.
it("returns discounted total", () => {
  const fake = () => 85;
  expect(cartTotal(100, "silver", fake)).toBe(85);
});

// After: assert on what cartTotal did, using a fake that exposes it.
it("rounds the discounted price to cents", () => {
  const fake = () => 85.004999;
  expect(cartTotal(100, "silver", fake)).toBe(85);
});

O teste “before” continuaria passando se cartTotal ignorasse seus argumentos e retornasse 85. O teste “after” entrega ao fake um valor que só sai correto se a lógica de arredondamento rodar, então ele observa a função em vez do stub.

Correção Um: Dê à Geração de Testes Unitários por IA o Requisito, Não o Código

Pedir a um modelo que escreva testes para o nome de uma função lhe dá apenas a implementação como base. Fornecer as regras de negócio como requisitos escritos lhe dá algo que o código não pode fornecer: uma especificação capaz de discordar do código.

O prompt fraco:

Write unit tests for calculateDiscount.

O prompt mais forte:

Write unit tests for calculateDiscount in discount.ts.

Rules:
- Silver tier is 15% off the price.
- Gold tier is 25% off the price.
- Price must be non-negative; a negative price throws.
- The result is rounded to two decimal places.

Compute every expected value from these rules, not from the
current implementation. Include at least one invalid-input case
per rule. Name each test as the rule it checks.

O segundo prompt produz expect(calculateDiscount(100, "silver")).toBe(85) porque 85 é o que a regra determina, e ele falha contra a função com bug na primeira execução. Nomes de teste que se leem como regras (“charges 85 for a 100 silver order”) também tornam a suíte revisável como uma especificação. O prompt não garante que o modelo ignore o código-fonte; ele dá ao modelo uma fonte da verdade com precedência sobre ele.

Correção Dois: Quebre o Código e Espere o Vermelho

A verificação mais rápida de uma suíte de testes gerada é alterar uma constante ou inverter uma comparação no código sob teste e executar os testes novamente. Se tudo permanecer verde, a suíte não estava protegendo aquele código.

1. Fix the bug in discount.ts (silver rate to 0.15), then change 0.15 to 0.05.
2. Run your test command.
3. Expected: "charges 85" fails with received 95.
   If nothing fails, no test asserts the silver rate.

O mutation testing automatiza isso. O StrykerJS (@stryker-mutator/core) injeta pequenas alterações no código-fonte, reexecuta a suíte para cada uma delas e reporta um mutation score, que divide os mutantes capturados pelos seus testes (um teste falhou, ou a execução atingiu timeout) pelo número de mutantes válidos. Seus mutadores suportados invertem operadores de comparação, negam booleanos, trocam operadores aritméticos, esvaziam literais de string e removem corpos de bloco. Existem plugins de runner para Jest e para Vitest; verifique a compatibilidade do plugin com a versão instalada antes de adotá-lo.

Correção Três: Leia os Mocks Antes das Asserções

Revise o diff de um teste gerado nesta ordem: o que está sendo falsificado, de onde vieram os valores esperados e, por último, o que está sendo afirmado. O que está falsificado determina o que o teste pode observar. De onde veio o valor esperado determina se a asserção pode discordar do código. A asserção em si é a linha menos informativa.

  • Um mock ocupando o lugar da unidade sob teste: exclua o teste.
  • Um valor esperado que é uma chamada de função, ou um literal sem regra por trás: recalcule-o a partir do requisito.
  • Mocks apenas para fronteiras reais (banco de dados, rede, relógio) e asserções sobre a saída da própria unidade: mantenha.

Preserve o Julgamento

A IA é confiável nas partes mecânicas dos testes: fixtures, setup e teardown, tabelas parametrizadas de casos quase idênticos, boilerplate para caminhos de erro assíncronos. Ela não é confiável ao decidir quais comportamentos vale a pena afirmar, porque essa decisão vive no requisito, não no código. Gere o scaffolding, depois tome o teste mais importante do diff, quebre a linha que ele deveria proteger e confirme que ele fica vermelho antes de fazer o merge.

Perguntas Frequentes

O mutation testing substitui a cobertura de código?

Não. Os dois reportam lacunas diferentes. A cobertura diz quais linhas nunca foram executadas por nenhum teste; o mutation testing diz quais linhas executadas nenhuma asserção protege. O StrykerJS reporta ambos: um mutante em código não executado recebe o estado No coverage, enquanto um mutante em código executado no qual todos os testes continuam passando é Survived. O mutation score conta os dois como não detectados, então baixa cobertura reduz o score diretamente.

O StrykerJS funciona com Vitest e Jest?

Sim, por meio de plugins de runner. Para o Vitest, instale @stryker-mutator/vitest-runner e defina testRunner como vitest na configuração do Stryker; o plugin não inclui o Vitest em si, então leia seu package.json para descobrir a versão mais antiga do Vitest que ele aceita. Testes executados pelo Browser Mode do Vitest ficam fora do que o runner trata. Para o Jest, use @stryker-mutator/jest-runner. Sob o runner do Vitest, o Stryker desativa a própria coleta de cobertura do Vitest e faz cada execução de mutante parar no primeiro teste que falha, porque uma falha basta para matar o mutante.

Como identifico testes gerados por IA que não contêm nenhuma asserção?

Faça o framework falhar qualquer teste com zero asserções. No Vitest, defina expect.requireAssertions na configuração ou passe --expect.requireAssertions na CLI; um teste que termina sem chamar expect falha. No Jest, chame expect.hasAssertions() dentro do corpo do teste, ou em um hook beforeEach para aplicá-lo em todos os lugares. Nenhuma dessas configurações verifica se uma asserção poderia falhar, então um teste que afirma o próprio valor de retorno de um mock ainda passa.

Dá para confiar em testes de snapshot gerados por IA?

Não sem revisão. Um snapshot gravado a partir da saída atual afirma apenas que o código continua produzindo o que produziu no momento da gravação, bugs incluídos, que é a mesma tautologia de um literal copiado. Regravar está a uma flag de distância: jest -u e vitest -u reescrevem todo snapshot que falha. No CI, a flag --ci do Jest falha ao encontrar novos snapshots em vez de gravá-los silenciosamente. Revise diffs de snapshot contra o requisito, não contra a saída anterior.

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.