Criando Sistemas Robustos com IA: Test-Driven Prompting

#Go#TDD#Testes#Mutation Testing#Qualidade de Software#LLM
Criando Sistemas Robustos com IA: Test-Driven Prompting

Eu demorei mais tempo do que queria admitir para perceber que o problema não era a IA.

Era eu. Era como eu pedia as coisas.

Quando comecei a usar LLMs para escrever código sério — não aquele script de demonstração que você nunca vai pôr em produção, mas código que vai processar pedidos, mexer em dados reais, rodar em produção — o padrão era sempre o mesmo: eu descrevia o problema, a IA entregava algo, eu olhava e pensava "tecnicamente funciona". Aí, duas semanas depois, vinha o incidente.

O que eu não tinha entendido ainda: 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 veio quando comecei a tratar testes não como documentação do comportamento esperado, mas como o contrato matemático que a IA precisa honrar.


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: Estudos indicam que código gerado por LLM tem survival rates de mutantes 15-25% maiores que código humano com o mesmo coverage.

Tempo de leitura: ~12 minutos.


O Problema que Você Ainda Não Nomeou

Você pede para a IA criar um sistema de cache. Ela entrega. Tem interface limpa, tem comentários bem escritos, tem tratamento de erro. Você roda os testes — verde. 80% de coverage. Você comita.

Três dias depois, alguém descobre que o cache não está sendo invalidado quando o dado muda. O bug não estava em falta de teste. Estava em falta de teste certo.

Isso tem nome: overengineering silencioso + coverage falsa.

A IA não entende o domínio. Ela não sabe que dedup no seu sistema significa igualdade por chave de negócio, não por identidade de objeto. Ela não sabe que null nesse contexto significa "pular", não "usar padrão". Ela escreve código que compila, que passa nos testes que ela mesma criou, e que testa exatamente o que ela achou que deveria testar.

O problema não é que a IA erre. É que ela erra de um jeito difícil de ver.


TDD Como Limitador de Alucinação

A ideia do Test-Driven Prompting não é nova no sentido filosófico — é literalmente o Red-Green-Refactor do TDD tradicional. 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 não tem mais graus de liberdade para inventar abstrações. Ela tem uma tarefa estrita: fazer esses testes passarem.

// ❌ Prompt vago — abre espaço para 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 pode inventar um sistema de cache para o pagamento se os testes não cobrem cache. Ela não pode criar uma camada de abstração desnecessária se os testes só testam a interface pública. O escopo é o que você definiu.


Table-Driven Tests — o Melhor Formato para LLMs

Go tem uma tradição forte de testes em tabela. E não é coincidência que esse formato também seja excepcionalmente eficaz para trabalhar com IA.

Modelos de linguagem são muito bons em seguir padrões matriciais. Quando você apresenta uma tabela de input → output esperado, a IA consegue raciocinar sobre casos extremos que você não explicitou em prosa.

Vou dar um exemplo concreto.

Em vez de descrever o comportamento de uma função de desconto em linguagem natural, 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 vivem
        {"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ê fornece essa tabela para a IA e pede para ela implementar CalculateDiscount, ela não tem como ignorar o caso discount greater than amount. Ele está ali, explícito, testado.

A IA vai escrever o código para aquele caso. Não porque ela pensou nisso. Porque você forçou 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 das pessoas ainda erra — inclusive eu errei por muito tempo.

Coverage de 100% não significa que seus testes são bons. Significa que cada linha foi executada ao menos uma vez. Executada com quais valores? Com que verificações? Isso a métrica não diz.

Com código gerado por IA, esse problema é mais sério. Pesquisa recente mostra que survival rates de mutantes são 15-25% maiores em código gerado por LLM com coverage equivalente ao código humano. Mesma cobertura numérica, suíte de testes mais fraca.

Traduzindo: 92% de coverage pode esconder bugs de domínio que passam despercebidos por semanas.


Mutation Testing — a Métrica Definitiva

Testes de mutação trabalham ao contrário do coverage normal.

Em vez de medir "quantas linhas meus testes tocam", eles perguntam: "se eu introduzir um bug aqui, algum teste vai quebrar?"

A ferramenta injeta mutações no código — troca > por >=, inverte condições, remove retornos — e verifica se algum teste falha. Se nenhum falhar, o mutante sobrevive. Mutante sobreviveu = você tem um buraco na suíte.

Em Go, a ferramenta principal para isso é 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 código que seus testes não validam de verdade.

Vou 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 para amount <= 0 e o teste vai continuar passando — porque o teste 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 vai matar 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. Inclui os casos normais, os casos de borda, e os casos que você sabe que vão causar problema no domínio.

2. Dá a tabela para a IA implementar

O prompt vira algo como: "Implemente a função CalculateDiscount que faz todos esses testes passarem. Sem adicionar funcionalidades além do necessário." A última frase importa. Sem ela, a IA vai querer adicionar cache, observabilidade, e um sistema de plugins.

3. Verifica cobertura básica

go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

Não como métrica final — como mapa para identificar 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 esse mutante e reimplemente se necessário."

5. Repete até mutation score satisfatório

Para lógica de negócio crítica, eu miro em 85%+ de mutation score. Para código de infraestrutura, 70% já é razoável.


Armadilhas Comuns

Tabela muito pequena. Cinco casos não são suficientes para lógica não-trivial. A IA vai otimizar para os cinco casos e ignorar o resto do espaço de entrada. Pense em casos de borda antes de pedir o código.

Não especificar o que NÃO deve fazer. A IA ama adicionar abstrações. Se você quer uma função simples, diga explicitamente. "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 nova passa pelo gremlins. Quando você descobre um mutante vivo em produção, já é tarde demais.

Confiar no primeiro Green. A IA às vezes escreve código que passa nos testes por acidente — especialmente quando a tabela tem casos redundantes. Revisa a implementação depois que os testes passam.


O que Aprendi

  1. Vaguidade é o inimigo, não a IA. Quanto mais específico o contrato, melhor o código. Isso vale para humanos e para LLMs.

  2. Table-driven tests são a interface natural com modelos de linguagem. Padrão matricial, casos explícitos, expectativas claras. A IA se sai bem nesse formato porque ela foi treinada em muito código assim.

  3. Coverage é um mapa, não um destino. Já vi sistemas com 95% de coverage quebrarem em produção de formas que os testes nunca capturariam. Mutation score é a métrica certa.

  4. O gremlins vai encontrar buracos que você jurava que não existiam. Já aconteceu comigo mais vezes do que quero admitir. O primeiro run num projeto legado costuma ser humilhante.

  5. O fluxo TDP reduz o tempo de revisão de código. Quando o código vem com uma tabela de testes e mutation score documentado, a revisão vira checagem de raciocínio — não de bugs.


Se você já usa TDD e começou a usar IA para escrever código, seria interessante trocar ideia sobre como está estruturando os prompts. Me conta no X: @orlandocbit.

Compartilhe este artigo

NEWSLETTER

Receba os próximos artigos e drops

Sem frequência forçada. Só quando tiver algo que vale o clique.

Artigos Relacionados

Comentarios