MÓDULO 13 · CONCEITO 05 DE 15

Geração de Testes com IA

O que funciona, o que não funciona, e a combinação que produz suítes de teste que realmente protegem

Tempo de leitura ~22 min Pré-requisito 04 · Prompting para engenharia · Módulo 02 · Testes e TDD Próximo 06 · Code review com IA

Geração de testes é uma das aplicações de LLMs que mais rapidamente se torna um hábito — e uma das que mais facilmente cria uma falsa sensação de segurança. Um LLM pode gerar cinquenta testes unitários para uma classe em segundos. A questão é: esses cinquenta testes protegem o sistema? Ou são cinquenta afirmações de comportamento óbvio que já é evidente pelo código, enquanto os casos que realmente importam — os edge cases de domínio, os comportamentos sob falha, as invariantes de negócio — ficam descobertos?

A resposta depende inteiramente de como a geração de testes é usada. Há dois modos de operação: LLM gerando testes para código que já existe (retroativo), e LLM implementando código para satisfazer testes que o humano escreveu primeiro (TDD-first). O primeiro modo tem valor real mas risco específico. O segundo modo é onde a combinação de TDD e LLM produz resultados robustos — e é o padrão que este conceito defende como o correto para código não-trivial.

O módulo 02 desta formação (Testes, TDD e Qualidade) estabeleceu a base conceitual: a diferença entre pirâmide de testes e troféu, o que caracteriza um teste bom, por que mutation testing é o único jeito de saber se os testes têm força real. Este conceito parte daí e responde à pergunta específica: como LLMs se encaixam nessa base? O que eles adicionam, o que eles não podem adicionar, e como estruturar o uso para que a suíte de testes resultante seja genuinamente melhor do que a que existiria sem LLM.

O que LLMs fazem bem em geração de testes

Testes de regressão para código existente. Dado código que já existe e comporta-se corretamente, um LLM pode gerar testes que documentam esse comportamento. O valor é real: código sem testes que vai ser refatorado precisa de testes de regressão antes da refatoração, e gerá-los manualmente é trabalho tedioso e mecânico. LLMs fazem isso rápido e razoavelmente bem para os casos comuns.

Testes de happy path e casos óbvios. "Dado input válido, retorna output esperado" é o caso mais fácil de gerar e o que LLMs cobrem mais confiavelmente. Para endpoints CRUD, funções de transformação, e código com comportamento claro, o LLM vai gerar os casos básicos corretos.

Estrutura e boilerplate. Setup de contexto de teste, mocks de dependências, estrutura de assertivas — a parte mecânica de escrever testes. LLMs são excelentes nisso, especialmente quando você fornece um arquivo de teste existente como referência de padrão. O setup de um TestContainers, a configuração de um WebApplicationFactory, a estrutura de fixtures do pytest — tudo que é repetitivo e segue padrão estabelecido.

Testes de contrato de API. Dado um schema OpenAPI ou definição de endpoint, gerar testes que verificam que cada campo existe na resposta, que os tipos estão corretos, que os campos obrigatórios estão presentes. Mecânico, tedioso manualmente, adequado para geração automática.

O que LLMs fazem mal — e por que importa

Casos de borda de domínio não-óbvios. Um LLM gerando testes para um sistema de pagamentos vai cobrir: pagamento com valor positivo (sucesso), pagamento com valor zero (erro), pagamento com valor negativo (erro). O que ele não vai cobrir automaticamente: o que acontece quando o saldo do usuário é exatamente igual ao valor do pagamento e há uma segunda transação concorrente? O que acontece quando a moeda do cartão difere da moeda do pedido e a taxa de câmbio muda entre autorização e captura? Esses casos de borda existem no domínio de pagamentos — qualquer engenheiro que já trabalhou nessa área sabe deles — mas não são deduzíveis do código sem esse conhecimento de domínio.

Invariantes de negócio implícitas. "Um pedido não pode ser cancelado depois que o pagamento é capturado" não está no código de geração de testes — está no modelo de negócio. Se o LLM não tem essa informação explicitamente no contexto, ele não vai gerar o teste que verifica essa invariante. Invariantes implícitas são exatamente o tipo de coisa que os testes mais importantes precisam verificar — e exatamente o que o LLM não pode inferir.

Comportamento sob falha de dependências externas. O que acontece quando o banco de dados retorna timeout? Quando o serviço de email falha no meio do envio? Quando o cache retorna dado corrompido? LLMs geram happy path com facilidade; geração de testes de failure path requer conhecimento de quais falhas são possíveis e qual o comportamento correto em cada uma — informação que pertence ao engenheiro que conhece o sistema.

Testes que verificam ausência de comportamento. "O endpoint não deve expor o campo internal_cost_price na resposta" é um requisito de segurança importante. LLMs raramente geram testes que verificam o que o código não deve fazer — focam no que ele deve fazer. Testes de segurança, testes de autorização ("usuário sem permissão não pode acessar esse recurso"), e testes de confidencialidade de dados frequentemente ficam descobertos em suítes geradas automaticamente.

armadilha em produção

Alta cobertura de código gerada por LLM não é equivalente a suíte de testes forte. Cobertura mede quais linhas foram executadas pelos testes — não quais comportamentos foram verificados. Uma suíte com 90% de cobertura gerada por LLM pode ter zero testes de invariante de domínio, zero testes de failure path, e zero testes de segurança. O código está "coberto" mas não está protegido. Mutation testing (módulo 02) revela isso com dados.

TDD-first com LLM implementando — o padrão correto

A combinação mais robusta de TDD e LLM inverte a ordem usual de geração automática: o humano escreve os testes, o LLM implementa o código que faz os testes passarem. Esse padrão preserva o valor fundamental do TDD — o teste como especificação executável do comportamento — e usa o LLM no papel em que ele é mais confiável: implementar um comportamento claramente definido.

O ciclo é: o engenheiro escreve os testes que definem o comportamento esperado, incluindo os casos de borda de domínio, as invariantes de negócio, e os failure paths que ele conhece. Os testes estão vermelhos — o código de produção não existe ainda. O engenheiro instrui o LLM: "implemente o código que faz esses testes passarem". O LLM implementa; o engenheiro verifica que todos os testes passam e que o código é correto.

O que esse padrão garante: os testes foram escritos pelo humano que conhece o domínio, então cobrem os casos que importam. O LLM implementa o código que satisfaz a spec executável — uma tarefa em que ele é razoavelmente confiável. O critério de correção é objetivo: os testes passam ou não passam. A responsabilidade pela qualidade da suíte de testes fica com o engenheiro, onde deve ficar.

O overhead em relação a "LLM gera tudo" é escrever os testes antes. Mas esse overhead é exatamente o trabalho de design que o TDD força — articular o comportamento esperado antes de implementar. Com LLM como implementador, o custo da implementação é baixo; o valor do exercício de escrever os testes primeiro é preservado. Em código de domínio complexo, esse é o único padrão que produz suítes de teste em que o engenheiro pode confiar.

C# — testes escritos primeiro, LLM implementa
// Engenheiro escreve os testes (RED):
public class OrderCancellationServiceTests
{
    [Fact]
    public async Task Cancel_PendingOrder_Succeeds()
    {
        var order = OrderBuilder.Pending().WithId("ord-1").Build();
        var sut = new OrderCancellationService(/* deps */);

        var result = await sut.CancelAsync("ord-1", userId: order.UserId);

        result.IsSuccess.Should().BeTrue();
        result.Value.Status.Should().Be(OrderStatus.Cancelled);
    }

    [Fact]
    public async Task Cancel_ShippedOrder_Fails()
    {
        var order = OrderBuilder.Shipped().WithId("ord-2").Build();
        // ...
        result.IsFailure.Should().BeTrue();
        result.Error.Should().Be(OrderErrors.CannotCancelShippedOrder);
    }

    [Fact]
    public async Task Cancel_OtherUsersOrder_Fails()
    {
        // invariante de segurança: ownership
        result.Error.Should().Be(OrderErrors.Forbidden);
    }

    [Fact]
    public async Task Cancel_AlreadyCancelled_IsIdempotent()
    {
        // chamadas repetidas não falham
        result.IsSuccess.Should().BeTrue();
    }

    [Fact]
    public async Task Cancel_PaymentCaptured_Fails()
    {
        // invariante de negócio crítica — não deduzível do código
        result.Error.Should().Be(OrderErrors.PaymentAlreadyCaptured);
    }
}

// Instrução ao LLM:
// "Implemente OrderCancellationService em
//  Services/OrderCancellationService.cs de forma que
//  todos os testes em OrderCancellationServiceTests passem.
//  Use IOrderRepository e IPaymentService (ver interfaces).
//  Retorne Result<OrderDto> em todos os casos."

Os testes escritos pelo engenheiro capturam as invariantes de domínio (shipped não cancela, ownership, idempotência, pagamento capturado) que o LLM nunca geraria automaticamente. O LLM implementa satisfazendo a spec executável.

Python — testes de failure path que LLM não gera automaticamente
import pytest
from unittest.mock import AsyncMock, patch

# Testes que o LLM raramente gera sem instrução explícita:

class TestOrderCancellationFailurePaths:

    async def test_database_timeout_raises_retryable_error(self):
        """Se o DB retorna timeout, deve ser retryable — não mascarar"""
        repo = AsyncMock()
        repo.get_order.side_effect = DatabaseTimeoutError()
        svc = OrderCancellationService(repo=repo)

        with pytest.raises(RetryableError):
            await svc.cancel("ord-1", user_id="u-1")

    async def test_event_publish_failure_rolls_back(self):
        """Se o evento falha após o save, o pedido deve ser revertido"""
        repo = AsyncMock()
        event_bus = AsyncMock()
        event_bus.publish.side_effect = EventBusUnavailableError()
        svc = OrderCancellationService(repo=repo, event_bus=event_bus)

        with pytest.raises(EventBusUnavailableError):
            await svc.cancel("ord-1", user_id="u-1")

        # pedido NÃO deve estar cancelado (rollback implícito)
        repo.update_status.assert_not_called()

    async def test_does_not_leak_internal_details_in_error(self):
        """Erros não devem expor stack trace ou detalhes internos"""
        # ... setup para provocar erro interno
        error = result.error
        assert "Traceback" not in str(error)
        assert "sql" not in str(error).lower()
        assert "database" not in str(error).lower()

# Instrução ao LLM:
# "Implemente OrderCancellationService em services/orders.py
#  de forma que todos os testes passem, incluindo os de
#  failure path. A implementação deve usar transação do banco
#  para garantir consistência entre update e publish do evento."

Testes de rollback em falha de evento, não-vazamento de detalhes internos, e erros retryáveis são exatamente os que nunca aparecem em geração automática — e os que protegem contra os bugs mais sérios em produção.

Go — property-based testing com LLM sugerindo propriedades
// Engenheiro define as propriedades; LLM ajuda a implementar:
//
// Propriedades para OrderCancellationService.Cancel():
//
// P1: Idempotência
//   Para qualquer pedido cancelável, Cancel() chamado N vezes
//   produz o mesmo estado que chamado 1 vez.
//
// P2: Ownership preservado
//   Para qualquer par (orderId, userId) onde userId != order.UserID,
//   Cancel() nunca retorna sucesso.
//
// P3: Status é terminal
//   Uma vez cancelado, nenhuma operação pode reverter para Pending.
//
// Instrução ao LLM:
// "Implemente os testes das propriedades P1, P2, P3 acima
//  usando a library rapid (github.com/flyingmutant/rapid).
//  Cada propriedade deve gerar inputs aleatórios via rapid.Gen.
//  Referência: ver property_test.go no pacote payments/
//  para o padrão de uso de rapid nesse repositório."

// LLM gera a implementação dos testes property-based;
// o engenheiro definiu as propriedades que importam:
func TestCancelIdempotent(t *testing.T) {
    rapid.Check(t, func(t *rapid.T) {
        order := genCancellableOrder(t)
        svc := newTestService()

        result1, _ := svc.Cancel(context.Background(), order.ID, order.UserID)
        result2, _ := svc.Cancel(context.Background(), order.ID, order.UserID)

        if result1.Status != result2.Status {
            t.Fatalf("Cancel is not idempotent: %v != %v",
                result1.Status, result2.Status)
        }
    })
}

LLMs são bons em implementar property-based tests quando o engenheiro define as propriedades. A definição de propriedades requer conhecimento de domínio; a implementação em código rapid/gopter/Hypothesis é mecânica.

LLM retroativo: gerando testes para código sem cobertura

Nem sempre é possível seguir TDD-first. Existe código legado, código de terceiros que precisa de testes de integração, código que foi escrito antes da prática ser estabelecida no time. Para esses casos, LLMs são genuinamente úteis para gerar testes retroativos — com cuidados específicos.

O protocolo para geração retroativa eficaz: fornecer o código e pedir primeiro que o LLM descreva o comportamento que observa antes de gerar os testes ("antes de gerar, descreva em linguagem natural o que esse código faz e quais casos de borda você consegue identificar pelo código"). Essa etapa de descrição tem dois benefícios: identifica o entendimento que o LLM tem do código (que pode ser incompleto), e produz uma lista de casos de teste que o engenheiro pode revisar e complementar com conhecimento de domínio antes de o LLM escrever o código de teste.

Complementar a lista gerada pelo LLM com casos de domínio que ele não pode inferir é o passo crítico. O engenheiro lê a lista, adiciona os casos que faltam ("adicione um caso onde o usuário tem saldo negativo por causa de um estorno", "adicione um caso onde o coupon foi usado em outra conta simultaneamente"), e só então instrui o LLM a gerar o código dos testes para a lista completa.

Após a geração, mutation testing (Stryker para C#, mutmut para Python, go-mutesting para Go) é a verificação mais honesta da qualidade real da suíte. Se os mutantes sobrevivem em alta proporção, os testes gerados documentam comportamento mas não o verificam com força. Isso é informação acionável: quais comportamentos estão sub-testados, quais partes do código podem mudar sem que os testes detectem.

Testes de integração e end-to-end com LLM

Testes unitários são onde a geração por LLM tem melhor relação custo-benefício. Testes de integração e end-to-end envolvem mais contexto de infraestrutura e comportamento emergente de múltiplos componentes — o que torna a geração automática menos confiável, mas não inútil.

Para testes de integração com Testcontainers, o LLM é especialmente útil na parte de setup: configurar o container, inicializar o banco, rodar migrations, e limpar estado entre testes. Essa infraestrutura é mecânica e bem documentada — o LLM cobre bem. Os scenarios de integração em si (o que testar na interação entre componentes) requerem julgamento do engenheiro sobre quais integrações têm risco de falha.

Para testes end-to-end com Playwright, Cypress, ou similar, o LLM pode gerar os testes dado um screenshot ou descrição do fluxo — mas a manutenção de testes E2E é notoriamente cara, e gerar mais testes do que o time consegue manter é criar dívida técnica disfarçada de cobertura. A regra prática: gerar testes E2E com LLM para os fluxos críticos de negócio (checkout, login, criação de conta), não para todos os fluxos possíveis.

Como praticar

  1. Mutation score de suíte gerada vs suíte TDD-first. Pegue um módulo com código que você vai implementar. Implemente com LLM gerando testes e código ao mesmo tempo. Rode mutation testing. Implemente outro módulo similar seguindo TDD-first: escreva os testes você, instrua o LLM a implementar. Compare os mutation scores. A diferença quantifica o valor do TDD-first.
  2. Auditoria de casos de domínio. Pegue uma suíte de testes gerada por LLM para código existente. Para cada domínio de regras de negócio que o código implementa, pergunte: quais invariantes de domínio eu conheço que não estão verificadas por nenhum teste desta suíte? Escreva os testes faltantes. Esse exercício calibra o gap entre o que o LLM cobre automaticamente e o que requer conhecimento de domínio.
  3. Protocolo de geração retroativa. Para um módulo legado sem testes, aplique o protocolo descrito acima: LLM descreve o comportamento primeiro, você complementa com casos de domínio, LLM gera o código dos testes. Ao final, rode a suíte gerada — todos os testes passam (o código já existe)? Se não, quais falham? Falhas revelam comportamento que o LLM assumiu incorretamente — informação sobre onde o código é confuso ou inconsistente.

Referências para aprofundar

  1. livro Test-Driven Development: By Example — Kent Beck (2002). A fundação. O princípio de TDD-first que este conceito defende como o padrão correto com LLMs é o mesmo que Beck articulou — a IA muda o implementador, não a disciplina.
  2. livro Working Effectively with Legacy Code — Michael Feathers (2004). Geração retroativa de testes em código sem cobertura é exatamente o problema que Feathers trata. As técnicas de "seam" para tornar código testável são aplicáveis independente de quem escreve os testes.
  3. artigo An Introduction to Property-Based Testing — Scott Wlaschin (2014). fsharpforfunandprofit.com — A melhor introdução a property-based testing com exemplos concretos de como pensar em propriedades. Diretamente aplicável à seção de definição de propriedades para LLM implementar.
  4. docs Stryker Mutator Documentation — Stryker Team. stryker-mutator.io — Documentação do Stryker para C# e JavaScript. Essencial para entender como mutation testing revela a força real de suítes geradas automaticamente.
  5. paper Large Language Models are Few-Shot Testers: Exploring LLM-Based General Bug Reproduction — Kang et al. (2023). arxiv.org/abs/2209.11515 — Estudo empírico sobre LLMs em reprodução de bugs via testes. Mostra onde LLMs adicionam valor real e onde falham na geração de testes.
  6. artigo ChatUniTest: A Framework for LLM-Based Test Generation — Chen et al. (2023). Comparação sistemática de abordagens de geração de testes por LLM. Dados empíricos sobre cobertura, taxa de compilação, e casos de borda cobertos vs não-cobertos.
  7. docs Hypothesis Documentation — HypothesisWorks. hypothesis.readthedocs.io — A biblioteca de property-based testing para Python. A documentação tem seções excelentes sobre como pensar em propriedades — o processo mental que o engenheiro precisa antes de passar a tarefa ao LLM.
  8. docs Testcontainers Documentation — Testcontainers. testcontainers.com — Setup de infraestrutura de teste (Postgres, Redis, Kafka) via containers. O LLM gera esse boilerplate bem; entender o que está sendo gerado requer a documentação.
  9. artigo How Good Are LLMs at Generating Unit Tests? — Schafer et al. (2024). Análise de 25 projetos open-source. Métricas: taxa de compilação, taxa de passagem, cobertura atingida, comparação com testes humanos. Calibra expectativas com dados reais.
  10. vídeo Property-Based Testing in Practice — John Hughes, StrangeLoop (2019). youtube.com — Hughes é o criador do QuickCheck. A palestra explica como encontrar propriedades em código real — o conhecimento que permite ao engenheiro definir propriedades para o LLM implementar.
  11. artigo Mutation Testing: From Theory to Maturity — Jia e Harman (2011). Visão geral de mutation testing como técnica. Explica por que mutation score é a métrica mais honesta de qualidade de suíte — mais do que cobertura de linha ou branch.
  12. livro Growing Object-Oriented Software, Guided by Tests — Freeman e Pryce (2009). "GOOS" — O livro sobre TDD outside-in com mocks. A perspectiva de que testes guiam design, não apenas verificam, é o argumento mais forte para TDD-first mesmo quando o LLM está disponível para implementar.