Eu demorei mais tempo do que queria admitir pra perceber que o problema não era a IA.
Era eu. Era como eu pedia as coisas.
A disciplina que me salvou aqui eu nem aprendi com IA. Aprendi antes, no braço, refatorando um serviço de fechamento de acordo que tinha virado um nó. Lógica de negócio crítica, quase nenhum teste, e tão acoplado que não dava nem pra exercitar um pedaço isolado sem desmontar metade do resto. Pra conseguir testar, primeiro tive que modularizar tudo — quebrar aquele bloco em partes que dava pra rodar sozinhas. Só depois veio o teste de verdade. No fim, mais de 400 cenários em table tests.
Foi nesse processo que a ficha caiu sobre o que um teste realmente é. Não é documentação do comportamento esperado. É um contrato. Cada linha daquela tabela era uma regra de negócio escrita de um jeito que não dava pra interpretar errado.
Anos depois, quando comecei a usar LLM pra escrever código sério — não o script de demo que você nunca põe em produção, mas código que mexe em dado real —, percebi que esse mesmo contrato era a única coisa que segurava a IA no lugar.
Porque IA é uma máquina de satisfazer objetivos. Se o objetivo for vago, ela vai satisfazer a versão mais criativa e genérica possível daquele objetivo. Não por maldade. Por natureza.
A virada de chave foi parar de descrever o que eu queria em prosa e começar a entregar o contrato matemático que a IA precisa honrar — a mesma tabela que eu tinha aprendido a escrever no braço.
TL;DR
O que é: Test-Driven Prompting (TDP) — usar testes falhos como restrição principal ao gerar código com IA.
O problema: IA sem restrições gera código correto na superfície, mas semanticamente fraco. Coverage alta, bugs de domínio escondidos.
A solução: Red primeiro. Testes de tabela. Mutation testing como métrica real de robustez.
O resultado: Código gerado pela IA que você consegue confiar de verdade — não por fé, por prova.
Números-chave: Pesquisa recente mediu suítes de teste com 100% de coverage e 4% de mutation score. Cobertura não mede o que você acha que mede.
Tempo de leitura: ~12 minutos.
O Problema que Você Ainda Não Nomeou
Você pede pra IA criar um sistema de cache. Ela entrega. Interface limpa, comentários bem escritos, tratamento de erro. Você roda os testes — verde. 80% de coverage. Comita.
Três dias depois alguém descobre que o cache não invalida quando o dado muda. O bug não estava na falta de teste. Estava na falta do teste certo.
Isso tem nome: overengineering silencioso + coverage falsa.
A IA não entende o teu domínio. Ela não sabe que dedup, no teu sistema, significa igualdade por chave de negócio — e não por identidade de objeto. Ela não sabe que null naquele contexto significa "pular", não "usar padrão". Ela escreve código que compila, que passa nos testes que ela mesma criou, e que valida exatamente aquilo que ela achou que deveria validar.
O problema não é que a IA erre. É que ela erra de um jeito difícil de ver.
E código difícil de ver é exatamente o tipo que espera a sexta-feira certa pra estourar.
TDD Como Limitador de Alucinação
A ideia do Test-Driven Prompting não é nova no sentido filosófico — é literalmente o Red-Green-Refactor de sempre. O que muda é o porquê funciona tão bem com IA.
Quando você fornece os testes falhos antes do código, você cria um túnel. A IA perde os graus de liberdade pra inventar abstração. Sobra uma tarefa estrita: fazer esses testes passarem.
// ❌ Prompt vago — abre espaço pra overengineering
// "Crie um sistema de processamento de pagamentos com retry"
// ✅ Prompt com contrato — fecha o espaço de solução
// "Faça esses testes passarem:"
func TestProcessPayment(t *testing.T) {
t.Run("rejects negative amount", func(t *testing.T) {
_, err := ProcessPayment(Payment{Amount: -100})
if err == nil {
t.Fatal("expected error for negative amount")
}
})
t.Run("rejects expired card", func(t *testing.T) {
payment := Payment{
Amount: 500,
Card: "4111111111111111",
ExpiryDate: "01/2020", // expired
}
_, err := ProcessPayment(payment)
if err == nil {
t.Fatal("expected error for expired card")
}
})
t.Run("approves valid payment", func(t *testing.T) {
payment := Payment{
Amount: 500,
Card: "4111111111111111",
ExpiryDate: "12/2027",
}
result, err := ProcessPayment(payment)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Status != "approved" {
t.Errorf("expected 'approved', got '%s'", result.Status)
}
})
}
A IA não inventa um cache pro pagamento se os testes não cobrem cache. Não cria uma camada de abstração desnecessária se os testes só exercitam a interface pública. O escopo é o que você definiu. Nem mais, nem menos.
Table-Driven Tests — o Melhor Formato pra LLMs
Go tem uma tradição forte de teste em tabela. E não é coincidência que esse formato também seja excepcionalmente eficaz com IA.
Modelo de linguagem é muito bom em seguir padrão matricial. Quando você apresenta uma tabela de input → output esperado, a IA consegue raciocinar sobre casos extremos que você nem explicitou em prosa.
Vou dar um exemplo concreto.
Em vez de descrever o comportamento de uma função de desconto em texto corrido, você faz isso:
func TestCalculateDiscount(t *testing.T) {
cases := []struct {
name string
originalPrice float64
coupon string
expected float64
wantErr bool
}{
// Casos normais
{"no coupon", 100.0, "", 100.0, false},
{"10% coupon", 100.0, "DISCOUNT10", 90.0, false},
{"50% coupon", 200.0, "HALF", 100.0, false},
// Casos de borda — onde os bugs moram
{"zero amount", 0.0, "DISCOUNT10", 0.0, false},
{"invalid coupon", 100.0, "DOESNOTEXIST", 0.0, true},
{"expired coupon", 100.0, "EXPIRED2023", 0.0, true},
// Casos que a IA normalmente esquece
{"discount greater than amount", 50.0, "DISCOUNT90", 5.0, false},
{"negative amount", -10.0, "", 0.0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result, err := CalculateDiscount(tc.originalPrice, tc.coupon)
if tc.wantErr && err == nil {
t.Fatal("expected error, got none")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !tc.wantErr && result != tc.expected {
t.Errorf("expected %.2f, got %.2f", tc.expected, result)
}
})
}
}
Quando você dá essa tabela pra IA e pede pra implementar CalculateDiscount, ela não tem como ignorar o caso discount greater than amount. Ele tá ali, explícito, testado.
A IA vai escrever o código pra aquele caso. Não porque ela pensou nisso. Porque você obrigou ela a pensar nisso.
A mágica não tá no código. Tá na tabela.
O Problema que Coverage Não Resolve
Aqui é onde a maioria ainda erra — e eu errei por tempo demais.
Coverage de 100% não diz que seus testes são bons. Diz que cada linha foi executada pelo menos uma vez. Executada com quais valores? Com que verificação no final? Isso a métrica não conta.
Com código de IA, o problema piora. Tem pesquisa recente que mediu suítes geradas por LLM com coverage cravando 100% e mutation score de 4%. Pensa nisso por um segundo: cada linha rodou, todo mundo verde no pipeline, e mesmo assim os testes capturavam praticamente nada de comportamento errado.
Traduzindo: 92% de coverage pode estar escondendo bug de domínio que vai passar batido por semanas. Até a sexta certa.
Mutation Testing — a Métrica Definitiva
Teste de mutação trabalha ao contrário do coverage normal.
Em vez de medir "quantas linhas meus testes tocam", ele pergunta: "se eu plantar um bug aqui, algum teste quebra?"
A ferramenta injeta mutações no código — troca > por >=, inverte condição, remove retorno — e checa se algum teste falha. Se nenhum falhar, o mutante sobrevive. Mutante sobreviveu = você tem um buraco na suíte.
Em Go, a ferramenta principal é o gremlins:
# Instalar
go install github.com/go-gremlins/gremlins/cmd/gremlins@latest
# Rodar no módulo
gremlins unleash ./...
A saída é direta:
KILLED internal/payment/discount.go:23 - CONDITIONALS_BOUNDARY
LIVED internal/payment/discount.go:31 - NEGATE_CONDITIONALS
KILLED internal/payment/discount.go:45 - ARITHMETIC_BASE
NOT COVERED internal/payment/discount.go:58
LIVED é o sinal de problema. Significa que a IA escreveu um código que seus testes não validam de verdade.
Deixa eu mostrar o que isso parece na prática.
// Código gerado pela IA — parece correto
func CalculateDiscount(amount float64, coupon string) (float64, error) {
if amount < 0 {
return 0, errors.New("invalid amount")
}
discount := getDiscount(coupon)
return amount - (amount * discount / 100), nil
}
// ❌ Teste superficial — 100% coverage, mutante sobrevive
func TestCalculateDiscount(t *testing.T) {
result, err := CalculateDiscount(100.0, "DISCOUNT10")
if err != nil {
t.Fatal(err)
}
if result <= 0 {
t.Error("result should be positive")
}
}
O gremlins vai mutar amount < 0 pra amount <= 0 e o teste continua passando — porque ele nunca verifica o comportamento com amount == 0 especificamente. Mutante vivo.
// ✅ Teste que mata o mutante
func TestCalculateDiscount(t *testing.T) {
cases := []struct {
amount float64
coupon string
expected float64
wantErr bool
}{
{100.0, "DISCOUNT10", 90.0, false},
{0.0, "DISCOUNT10", 0.0, false}, // zero amount is valid
{-1.0, "DISCOUNT10", 0.0, true}, // negative amount is invalid
}
// ...
}
Com esse nível de especificidade, qualquer mutação na condição amount < 0 mata pelo menos um caso da tabela. Mutante morto.
O Fluxo Completo na Prática
O que eu uso hoje, na ordem certa:
1. Escreve a tabela primeiro (Red)
Antes de qualquer código, define os casos. Casos normais, casos de borda, e os casos que você sabe que vão dar dor de cabeça no domínio. Esse último grupo é o que separa quem já tomou incidente de quem ainda vai tomar.
2. Dá a tabela pra IA implementar
O prompt vira algo como: "Implemente a função CalculateDiscount que faz todos esses testes passarem. Não adicione funcionalidade além do necessário." A última frase importa. Sem ela, a IA vai querer te entregar cache, observabilidade e um sistema de plugins de brinde.
3. Verifica cobertura básica
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
Não como métrica final — como mapa pra enxergar o que não foi tocado.
4. Roda o gremlins
gremlins unleash ./internal/...
Cada LIVED é uma conversa com a IA: "Esse mutante sobreviveu. Adiciona um caso na tabela que mate ele e reimplemente se precisar."
5. Repete até o mutation score ficar de pé
Pra lógica de negócio crítica, eu miro 85%+ de mutation score. Pra código de infra, 70% já é razoável. Não é dogma — é o ponto onde eu durmo tranquilo.
Armadilhas Comuns
Tabela pequena demais. Cinco casos não bastam pra lógica não-trivial. A IA otimiza pros cinco e ignora o resto do espaço de entrada. Pensa nos casos de borda antes de pedir o código.
Não dizer o que NÃO fazer. A IA ama adicionar abstração. Se você quer uma função simples, fala isso na cara: "Não crie interfaces. Não crie tipos novos além dos necessários."
Usar mutation testing só no final. Integra no CI. Toda PR passa pelo gremlins. Quando você descobre um mutante vivo em produção, já é tarde — o nome disso é incidente.
Confiar no primeiro Green. Às vezes a IA passa nos testes por acidente, principalmente quando a tabela tem caso redundante. Lê a implementação depois que ficou verde. Verde não é prova, é convite pra olhar.
O que Aprendi
-
Vaguidade é o inimigo, não a IA. Quanto mais apertado o contrato, melhor o código. Vale pra humano e pra LLM igual.
-
Table-driven test é a interface natural com modelo de linguagem. Padrão matricial, casos explícitos, expectativa clara. A IA se vira bem nesse formato porque foi treinada em muito código assim.
-
Coverage é mapa, não destino. Já vi sistema com 95% de coverage quebrar em produção de jeito que os testes nunca pegariam. Mutation score é a métrica que importa.
-
O gremlins encontra buraco que você jurava que não existia. Já me aconteceu mais vezes do que eu queria admitir. O primeiro run num projeto legado costuma ser humilhante — ele te mostra, em texto, tudo que você vinha varrendo pra debaixo do tapete.
-
Não dá pra testar o que não é modular — e não dá pra fazer TDP no que não dá pra testar. Aquele serviço de fechamento de acordo da abertura só virou testável depois que eu quebrei o bloco em partes que rodavam isoladas. Os 400+ cenários em table tests vieram quase fáceis; o trabalho duro foi deixar o código capaz de recebê-los. Vale pra IA também: se você não consegue nem escrever a tabela, não é a IA que vai te salvar.
-
O fluxo TDP encurta a revisão de código. Quando o código chega com tabela de teste e mutation score documentado, a revisão vira checagem de raciocínio — não caça a bug.
Se você já usa TDD e começou a deixar a IA escrever código, seria interessante trocar ideia sobre como você tá estruturando os prompts. Principalmente os casos de borda que mais te pegaram. Me conta no X: @orlandocbit.