TDD ainda importa quando agentes escrevem o código?
Veja como o desenvolvimento orientado a testes controla agentes de IA: escreva testes Vitest primeiro, impeça alterações e verifique o código na CI.
Sim, e talvez até mais do que antes, só que como um contrato, não como um ritual. Quando um agente de código escreve a implementação, os testes escritos primeiro passam a ser a especificação. Eles definem o que é “pronto” antes de existir qualquer código e flagram o agente quando ele declara concluída uma tarefa que não está.
A maioria dos desenvolvedores que delega a implementação ao Claude Code, ao Cursor ou ao GitHub Copilot já viu isso acontecer. O agente anuncia que tudo está passando e a funcionalidade quebra no primeiro uso. Ou então um arquivo de teste foi reescrito discretamente para concordar com o código novo.
Este artigo apresenta o fluxo de trabalho test-first para agentes: um arquivo Vitest real, a instrução exata para acompanhá-lo, as formas como os agentes o contornam e os mecanismos de proteção que fecham essas brechas.
Principais pontos
- Testes escritos antes de o agente começar funcionam como uma especificação executável: o trabalho do agente é fazê-los passar, não decidir o que significa passar.
- Um teste escrito depois do código tende a descrever o que o código já faz. Por isso ele passa por construção e não verifica nada.
- Uma mensagem do agente dizendo que todos os testes passam é uma alegação. Uma execução de CI no commit enviado é a evidência.
- Se um mesmo diff altera tanto a implementação quanto as asserções que a avaliam, o agente corrigiu a própria prova. Revise as mudanças nos testes separadamente das mudanças no código.
- Dispense o test-first em protótipos descartáveis e em ajustes puramente visuais, nos quais o resultado esperado não pode ser descrito até que você o veja.
O que é red-green-refactor?
Red-green-refactor é o ciclo do TDD. Você escreve um teste que falha e descreve um comportamento (red) e, em seguida, escreve o mínimo de código necessário para fazê-lo passar (green). Com a suíte verde, você limpa o código sem mudar o que ele faz (refactor). Para uma revisão mais extensa com exemplos em React, veja TDD vs BDD in React.
Como funciona o fluxo de TDD com IA?
O TDD com IA funciona quando o humano escreve ou aprova os testes que falham e o agente recebe a instrução de fazê-los passar sem alterá-los. Os testes guardam os requisitos, e o agente fornece apenas a implementação. Os exemplos abaixo usam o Vitest 5.
Comece com um stub, para que o arquivo de teste importe algo real:
// src/cart/applyCoupon.ts
export type Coupon =
| { code: string; kind: 'percent'; value: number }
| { code: string; kind: 'fixed'; valueCents: number };
export function applyCoupon(
subtotalCents: number,
code: string,
coupons: Coupon[],
): number {
throw new Error('not implemented');
}
Depois, escreva a especificação:
// src/cart/applyCoupon.test.ts
import { describe, it, expect } from 'vitest';
import { applyCoupon, type Coupon } from './applyCoupon';
const coupons: Coupon[] = [
{ code: 'SAVE10', kind: 'percent', value: 10 },
{ code: 'FIVEOFF', kind: 'fixed', valueCents: 500 },
];
describe('applyCoupon', () => {
it('takes a percentage off the subtotal', () => {
expect(applyCoupon(2000, 'SAVE10', coupons)).toBe(1800);
});
it('takes a fixed amount off the subtotal', () => {
expect(applyCoupon(2000, 'FIVEOFF', coupons)).toBe(1500);
});
it('never returns a negative total', () => {
expect(applyCoupon(300, 'FIVEOFF', coupons)).toBe(0);
});
it('matches codes case-insensitively', () => {
expect(applyCoupon(2000, 'save10', coupons)).toBe(1800);
});
it('rejects unknown codes', () => {
expect(() => applyCoupon(2000, 'BOGUS', coupons)).toThrow('Unknown coupon');
});
});
Como o stub existe e lança um erro, os cinco testes falham em tempo de execução, e não por causa de um import inexistente. Essa é uma fase red limpa. O último teste merece um olhar mais atento: o stub de fato lança um erro, mas a mensagem não corresponde a 'Unknown coupon', então o teste continua falhando pelo motivo certo. Cada teste fixa uma regra que o agente, de outra forma, teria de adivinhar: desconto percentual versus valor fixo, o piso em zero, o tratamento de maiúsculas e minúsculas.
Em seguida, entregue a tarefa com restrições explícitas:
Implement applyCoupon in src/cart/applyCoupon.ts so every test in
src/cart/applyCoupon.test.ts passes.
Rules:
- Do not create, edit, rename, skip or delete any *.test.ts file.
- Do not add .skip, .only, or mocks of applyCoupon.
- If a test looks wrong, stop and explain why instead of changing it.
- When finished, run `npx vitest run src/cart` and paste the full output.
A linha “stop and explain” oferece ao agente uma saída legítima que não seja alterar o teste. A saída colada poupa um passo, mas não prova nada. Mais hábitos de prompt que se encaixam nesse ciclo estão em 5 Tips and Tricks for AI-Assisted Coding.
Como o test-first dá errado com agentes?
Os agentes tendem a buscar o caminho mais curto até uma execução verde, e três desses caminhos ignoram completamente a especificação.
Editar ou excluir testes. Quando a implementação é difícil, mudar a expectativa é mais fácil do que atendê-la. Um toBe(1800) alterado, um .skip novo ou um arquivo de teste que sumiu produzem uma execução verde com o requisito removido.
Asserções fracas. Um teste escrito depois do código tende a confirmar o que o código faz, não o que ele deveria fazer:
// Confirms the code ran, not that it's right
it('applies coupon', () => {
const result = applyCoupon(2000, 'SAVE10', coupons);
expect(result).toBeDefined();
expect(typeof result).toBe('number');
});
// Pins the behaviour
it('takes 10% off with SAVE10', () => {
expect(applyCoupon(2000, 'SAVE10', coupons)).toBe(1800);
});
O teste fraco com toBeDefined() passa se applyCoupon retornar 0, 2000 ou 999999. É por isso que a ordem de escrita importa: testes que o agente escreve depois do próprio código tendem a se parecer com a primeira versão. Os agentes também costumam inflar as suítes com muitas verificações superficiais como essa, um padrão abordado em the smoke tests agents keep writing.
Mockar o que está sendo testado. Mockar o módulo sob teste significa que o teste exercita o mock, e esse teste continua verde independentemente do que a implementação real faça:
import { vi } from 'vitest';
vi.mock('./applyCoupon', () => ({ applyCoupon: () => 1800 }));
// Every assertion now tests the mock.
O Vitest faz o hoisting de vi.mock para cima dos imports, então a factory substitui o módulo antes de o código real ser carregado. O applyCoupon real nunca é executado. Faça mock das fronteiras (rede, armazenamento, tempo), não da unidade que você está especificando.
Quais mecanismos de proteção mantêm a especificação fixa?
Os mecanismos de proteção funcionam quando são mecânicos. As instruções reduzem a adulteração de testes, e o CI e a revisão pegam o que as instruções deixam passar.
Revise os diffs de teste separadamente. Leia as mudanças em *.test.ts como uma revisão à parte, antes da implementação. No GitHub, o CODEOWNERS com revisão obrigatória de code owner garante que uma pessoa designada aprove qualquer mudança em caminhos de teste.
Bloqueie ou proíba a edição de testes pelo agente.
- Claude Code: adicione uma regra
permissions.deny, como"Edit(**/*.test.ts)", ao.claude/settings.json. As regras de negação de Edit cobrem as ferramentas de arquivo nativas do Claude, além dos comandos de arquivo que ele reconhece no Bash (comosedetee) e dos destinos de redirecionamento. Elas não cobrem scripts nem outros subprocessos que escrevem arquivos indiretamente. Trate a regra como um obstáculo, não como uma garantia. - Cursor: declare a regra nas project rules em
.cursor/rules. - GitHub Copilot: coloque-a em
.github/copilot-instructions.md. Tanto o Cursor quanto o Copilot também leem oAGENTS.md.
Deixe o CI decidir o que é “pronto”. Rode npx vitest run no commit enviado. No CI, o Vitest faz a execução falhar se encontrar .only, a menos que você passe --allowOnly ou defina allowOnly: true. Ele não detecta .skip, então adicione uma verificação de diff nas branches de implementação:
changed=$(git diff --name-only origin/main...HEAD -- '*.test.ts' '*.spec.ts')
if [ -n "$changed" ]; then
echo "Test files changed on an implementation branch:"
echo "$changed"
exit 1
fi
O checkout precisa ter histórico suficiente para que origin/main seja resolvido.
Verifique se as asserções testam comportamento. Para cada asserção, pergunte se uma implementação errada ainda conseguiria passar. Valores exatos, erros lançados e saídas visíveis sobrevivem a refatorações. Contagens de chamadas em helpers internos e verificações de tipo, não.
Mesmo uma suíte limpa tem um limite. Testes unitários passando mostram que o código trata os casos que alguém registrou. Eles não mostram que um usuário real consegue concluir o fluxo no navegador. Replays de sessão de uma funcionalidade já entregue podem revelar o checkout quebrando enquanto o CI está verde.
Quando o TDD não vale a pena?
O test-first custa mais do que retorna quando não é possível declarar o resultado esperado de antemão.
| Situação | Por que dispensar o test-first |
|---|---|
| Protótipos descartáveis | O código serve para responder a uma pergunta e depois ser excluído |
| Ajustes visuais de UI (espaçamento, cor, layout) | O resultado certo é avaliado olhando, então ainda não há nada a afirmar |
Se um protótipo sobreviver, escreva os testes antes de o agente transformá-lo em código de produção.
Conclusão
Quando os agentes escrevem o código, a especificação é a parte que só você pode fornecer, e os testes escritos primeiro a tornam executável. Na sua próxima tarefa com um agente, escreva primeiro o arquivo Vitest com os testes falhando e dê ao agente a instrução de não editá-lo. Faça o merge somente depois que o CI passar e uma revisão separada do diff de testes for aprovada.
Perguntas frequentes
Um linter consegue detectar testes pulados que um agente adiciona?
Sim. O pacote @vitest/eslint-plugin tem a regra vitest/no-disabled-tests, que sinaliza test.skip, it.skip e describe.skip, e a regra vitest/no-focused-tests, que sinaliza .only. A configuração recomendada apenas emite avisos para testes desativados, então defina essa regra como 'error' e rode o ESLint no CI. Projetos com Jest contam com regras de mesmo nome no eslint-plugin-jest. O lint pode deixar passar chamadas com alias, então mantenha também a verificação de diff do git.
Um agente de IA pode escrever os testes que falham no lugar de um humano?
Sim, desde que escrever os testes e escrever a implementação sejam etapas separadas. Faça uma sessão do agente redigir os testes com base no stub, revise você mesmo as asserções e faça o commit delas antes de a implementação começar. A sessão de implementação só deve ver testes que ela não tem permissão para alterar. Se a mesma sessão escrever os dois, os testes acabam descrevendo o próprio código dela, e não os requisitos.
Uma especificação escrita detalhada é suficiente sem escrever os testes primeiro?
Não. Uma especificação em texto ou um stub com comentários de documentação diz ao agente o que construir, mas nada confere o resultado contra ela. Um teste que falha não pode ser satisfeito por uma implementação que apenas parece plausível: ou ele passa, ou não passa. Use os dois. Coloque os requisitos e as restrições no prompt ou no arquivo de instruções e codifique como testes, executados pelo CI a cada commit, os casos que precisam ser atendidos.
O que fazer quando o agente diz que um teste está errado?
Analise você mesmo a explicação do agente e, se o teste estiver realmente errado, corrija-o fora da branch de implementação. Leve o teste corrigido para a main em um commit ou pull request próprio, revisado via CODEOWNERS se você o utiliza, e depois faça o rebase da branch de implementação. A verificação de diff compara a branch com origin/main, então uma correção de teste que já está na main não a dispara, e o agente nunca edita a especificação.