c. Testes com IA
Um modelo consegue escrever cinquenta testes unitários em um minuto. A pergunta difícil não é ele consegue escrever testes?, mas vale a pena manter esses testes? A cobertura não responde a isso: um teste que chama todos os métodos e não verifica nada chega a 100% de cobertura de linhas e não pega nenhum bug. Esta parte usa testes de mutação como juiz: um teste gerado só é mantido se pegar bugs que os testes existentes deixam passar.
2.1 Testes de mutação em um parágrafo
Uma ferramenta de testes de mutação faz pequenas alterações no código de produção — cada uma é um mutante: > vira >=, a * b vira a / b, um return price vira return 0, uma chamada de método é removida — e roda os testes contra cada mutante. Se algum teste falha, o mutante é morto (os testes perceberam o bug); se todos os testes continuam passando, ele sobreviveu (um bug que os testes não enxergam). O score de mutação é mortos ÷ total. O PIT é a ferramenta padrão para a JVM1.
flowchart LR
pr["PR altera<br/>CatalogService.java"] --> base["PIT com os testes existentes<br/>score de referência"]
base --> gen["Modelo escreve<br/>CatalogServiceAiTest"]
gen --> comp{"compila e<br/>passa duas vezes?"}
comp -->|"não: erros de volta"| gen
comp -->|"sim"| pit["PIT com testes existentes + novos"]
pit --> gain{"score ≥ referência + 5 pp?"}
gain -->|"não: sobreviventes de volta"| gen
gain -->|"sim"| pr2["PR em rascunho com o teste"] O loop dá ao modelo três tipos de feedback, nesta ordem: erros de compilação, testes falhando e a lista de mutantes sobreviventes ("linha 42: limite da condicional alterado em isExpired()"). Este último é a parte interessante: ele diz ao modelo exatamente qual comportamento não está testado, a mesma ideia por trás da geração de testes guiada por mutação relatada na Meta2.
2.2 Adicione testes unitários e o PIT ao catalog
O gerador precisa de um serviço com bibliotecas de teste e PIT. O Lab 11 já deu ao catalog as dependências de teste (os starters de teste do Spring Boot, que trazem JUnit Jupiter, Mockito e AssertJ, e o Testcontainers do teste de integração). O lab 13 acrescenta um bloco, marcado lab 13 no arquivo: o plugin do PIT com o adaptador JUnit 5, que também roda os testes JUnit 6 do Spring Boot 4. O PIT só roda quando você chama o goal dele, nunca num mvn verify comum. Substitua o arquivo — é o pom.xml do lab 11 mais esse bloco:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>boxoffice</groupId>
<artifactId>catalog</artifactId>
<version>1.0.0</version>
<name>catalog</name>
<description>BoxOffice catalog: events and venues</description>
<properties>
<java.version>21</java.version>
<springdoc.version>3.1.1</springdoc.version>
<!-- lab 11: the Trivy gate found fixable CRITICAL CVEs in the Tomcat of Spring Boot 4.1.1 (11.0.24);
drop this line when the parent brings 11.0.25 or later -->
<tomcat.version>11.0.27</tomcat.version>
</properties>
<dependencies>
<!-- REST API (Spring MVC on Tomcat) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Bean Validation (@NotBlank, @Positive, ...) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- JPA + Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- health, metrics -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- schema migrations (Boot 4: the starter brings the auto-configuration) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Redis client (Lettuce) + StringRedisTemplate: the cache of lab 5 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- OpenAPI document + Swagger UI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- lab 11: /actuator/prometheus, scraped in Kubernetes for the canary analysis -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- lab 11: tests (JUnit 5, AssertJ, TestRestTemplate) against real containers -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- lab 13: PIT mutation testing, run on demand (never in a plain `mvn verify`) -->
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.30.1</version>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.3</version>
</dependency>
</dependencies>
<configuration>
<outputFormats>
<param>XML</param>
<param>HTML</param>
</outputFormats>
<timestampedReports>false</timestampedReports>
</configuration>
</plugin>
</plugins>
</build>
</project>
E um primeiro teste de CatalogService escrito à mão, incompleto de propósito — JUnit + Mockito puros, sem contexto Spring e sem banco, então roda em um segundo:
package boxoffice.catalog;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/**
* Hand-written unit tests of CatalogService: plain JUnit + Mockito, no Spring context, no database.
* Deliberately incomplete -- lab 13 measures how much an AI-generated test class adds to them.
*/
class CatalogServiceTest {
private EventRepository events;
private VenueRepository venues;
private CatalogService service;
@BeforeEach
void setUp() {
events = mock(EventRepository.class);
venues = mock(VenueRepository.class);
service = new CatalogService(events, venues);
}
@Test
void deletingAnExistingEventDeletesIt() {
when(events.existsById(7L)).thenReturn(true);
service.deleteEvent(7L);
verify(events).deleteById(7L);
}
@Test
void deletingAMissingEventIs404AndDeletesNothing() {
when(events.existsById(7L)).thenReturn(false);
assertThatThrownBy(() -> service.deleteEvent(7L))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND);
verify(events, never()).deleteById(7L);
}
@Test
void findVenueReturnsItsFields() {
when(venues.findById(3L)).thenReturn(Optional.of(new Venue("Allianz Parque", "São Paulo", 43000)));
var venue = service.findVenue(3L);
assertThat(venue.name()).isEqualTo("Allianz Parque");
assertThat(venue.city()).isEqualTo("São Paulo");
assertThat(venue.capacity()).isEqualTo(43000);
}
}
Ponto de verificação. Na raiz do repositório:
O verify roda as duas classes de teste do catalog: o teste de integração CatalogApiTest do lab 11 (Testcontainers: o Docker precisa estar rodando) e o novo CatalogServiceTest. Você deve ver, perto do fim (a primeira execução baixa as bibliotecas de teste e as imagens postgres:17 e redis:8-alpine; os tempos variam):
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 10.41 s -- in boxoffice.catalog.CatalogApiTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.165 s -- in boxoffice.catalog.CatalogServiceTest
...
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
...
[INFO] BUILD SUCCESS
Um aviso Mockito is currently self-attaching to enable the inline-mock-maker é inofensivo. O Docker não está rodando agora? Rode só o novo teste unitário, que não precisa de contêiner: mvn -B -f services/catalog/pom.xml test -Dtest=CatalogServiceTest imprime Tests run: 3, Failures: 0, Errors: 0, Skipped: 0. O PIT (abaixo) e o gerador também rodam só testes unitários, então funcionam sem Docker.
Agora rode o PIT à mão uma vez numa classe para ver o relatório (HTML em services/catalog/target/pit-reports/index.html):
mvn -B -f services/catalog/pom.xml test-compile org.pitest:pitest-maven:mutationCoverage \
-DtargetClasses=boxoffice.catalog.CatalogService -DtargetTests='boxoffice.catalog.CatalogService*Test'
Você deve ver, perto do fim (uma execução em Java 21 enquanto este lab era escrito; os tempos dependem da sua máquina, as contagens não):
================================================================================
- Statistics
================================================================================
>> Line Coverage (for mutated classes only): 10/28 (36%)
>> 1 tests examined
>> Generated 18 mutations Killed 4 (22%)
>> Mutations with no coverage 14. Test strength 100%
>> Ran 4 tests (0.22 tests per mutation)
...
[INFO] BUILD SUCCESS
Leia: o PIT fez 18 mutantes de CatalogService; os três testes do CatalogServiceTest mataram 4 (um score de mutação de 22 %). Quatorze mutantes estão em código que nenhum teste sequer executa (findEvent, upcomingEvents, createEvent, ...). Abra o relatório HTML: cada linha vermelha é um bug que os seus testes não perceberiam. Essa lacuna é o que o gerador precisa fechar.
Mantenha o PIT focado
Testes de mutação são caros: cada mutante roda (parte da) suíte de testes. Num serviço inteiro, isso pode levar muitos minutos. Sempre mire nas classes que o PR alterou (-DtargetClasses) — é exatamente o que o script faz.
2.3 O gerador
| .github/ai/gen_tests.py | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | |
| Regra no código | Por quê |
|---|---|
Arquivo novo <Class>AiTest.java, nunca edita <Class>Test.java | Testes gerados nunca sobrescrevem os escritos por humanos; o review vê exatamente o que é novo |
Construções em FORBIDDEN | Nada de Thread.sleep (lento e instável), nada de @SpringBootTest (lento, precisa de banco), nada de @Disabled (um teste desabilitado é uma mentira) |
| Código-fonte do mesmo pacote no prompt | Para fazer mock de EventRepository, o modelo precisa das assinaturas reais dos métodos; caso contrário, o teste não compila | | Roda o teste duas vezes | Um teste que passa uma vez e falha na outra é pior do que nenhum teste | | env sem KEY/TOKEN/SECRET | O código sob teste, e o teste gerado, nunca veem a chave de API | | MIN_GAIN de 5 pontos percentuais | Um teste que não acrescenta nada mensurável é ruído a manter | | Apaga o arquivo se for rejeitado | Nada que não foi comprovado fica na árvore de trabalho | | Uma linha por tentativa no resumo | Você vê por que uma tentativa foi rejeitada: erro de compilação, teste falhando ou poucos mutantes mortos |
2.4 Teste localmente
Faça commit da parte 2 na main antes (o workflow da 2.5 faz parte dela — salve-o agora), para o gerador partir de uma árvore limpa:
git switch main
git add services/catalog/pom.xml services/catalog/src/test .github/ai/gen_tests.py .github/workflows/ai-tests.yaml
git commit -m "test(catalog): unit tests, PIT and the AI test generator (lab 13)"
git push
Depois rode o gerador em CatalogService, a partir da raiz do repositório. Ele precisa de mvn e do JDK 21 na sua máquina, e chama o modelo até três vezes; com o modelo local, espere 5–15 minutos:
uv run --no-project --python 3.12 --with-requirements .github/ai/requirements.txt \
python .github/ai/gen_tests.py services/catalog/src/main/java/boxoffice/catalog/CatalogService.java
git status --short # CatalogServiceAiTest.java exists only if it was accepted
O resumo compara o score de mutação com e sem a classe gerada, e diz por que cada tentativa foi aceita ou rejeitada. Uma execução com o qwen2.5-coder-16k local enquanto este lab era escrito:
### AI tests for `boxoffice.catalog.CatalogService`
| | Mutation score | Killed / total |
|---|---|---|
| Existing tests | 22% | 4 / 18 |
| With `CatalogServiceAiTest` | — (no attempt compiled and passed) | — |
Attempts: 3 · accepted: **False**
- 1. does not compile or fails: [ERROR] services/catalog/src/test/java/boxoffice/catalog/CatalogServiceAiTest.java:[57,91] cannot find symbol / [ERROR] services/catalog/src/test/java/boxoffice/catalog/CatalogServiceAiTest.java:[68,133] cannot find symbol
- 2. does not compile or fails: [ERROR] Tests run: 13, Failures: 0, Errors: 4, Skipped: 0, Time elapsed: 18.49 s <<< FAILURE! -- in boxoffice.catalog.CatalogServiceAiTest / [ERROR] boxoffice.catalog.CatalogServiceAiTest.createVenueCreatesVenue -- Time elapsed: 0.198 s <<< ERROR!
- 3. does not compile or fails: [ERROR] services/catalog/src/test/java/boxoffice/catalog/CatalogServiceAiTest.java:[45,53] cannot find symbol / [ERROR] services/catalog/src/test/java/boxoffice/catalog/CatalogServiceAiTest.java:[45,87] cannot find symbol
| Model | Calls | Input | Cache read | Cache write | Output | Time | Cost |
|---|---|---|---|---|---|---|---|
| `qwen2.5-coder-16k` | 3 | 10,604 | 0 | 0 | 5,466 | 454.0 s | n/a (local or unknown model) |
Rejeitado — e isso é o gerador funcionando, não falhando. O modelo gratuito de 7B inventou métodos (cannot find symbol) ou escreveu testes que lançam exceções; uma segunda execução com AI_TESTS_MAX_ATTEMPTS=5 falhou do mesmo jeito cinco vezes (21.278 tokens de entrada, ~16 minutos). Nada sem prova ficou para trás: o git status não mostra nenhum CatalogServiceAiTest.java. Um modelo mais forte (o Claude, ou um modelo local maior se a sua máquina tiver memória) tem mais chance de compilar — e ainda precisa superar a referência de 22 % em 5 pp; compare-os na parte 7. Se o seu for aceito, a segunda linha mostra o novo score e o git status lista o novo arquivo de teste.
Registre os seus números — modelo, tentativas, score inicial e final, minutos — para a parte 7.
2.5 O workflow
Gerar testes para todo PR seria lento e caro. O workflow roda sob demanda: adicione o label ai-tests a um PR. Ele tem dois jobs: generate roda o modelo e o build com um token somente leitura, e publish — o único job que pode escrever — aplica o patch e abre um PR em rascunho. O job que executa código não confiável (o código do PR e o código do modelo) nunca tem um token de escrita.
O job escolhe no máximo duas classes adicionadas ou modificadas pelo PR em src/main/java. Abra um PR pequeno que mexa em CatalogService — um comentário de uma linha na classe basta:
-@Service
+/** Use cases of the catalog: events and venues. */
+@Service
public class CatalogService {
git commit -am "docs(catalog): describe CatalogService"
git push -u origin catalog-docs
gh pr create --fill --base main
gh pr edit --add-label ai-tests # the label was created in step 0.4
gh pr checks --watch # "AI tests / generate" appears a few seconds after the label
Você deve ver o job generate terminar (20–45 minutos com o modelo gratuito num runner só com CPU: o modelo, dois builds Maven por tentativa e o PIT) e então:
- um PR em rascunho
AI tests for #N, com o labelai-generated, baseado emcatalog-docs, com um commit que leva o trailerAI-assisted: gen_tests.py (...)— confira comgh pr list --label ai-generated; ou publishpulado, e o resumo do jobgeneratelistando as tentativas rejeitadas. Esse é um resultado válido: anote o porquê.
Faça o merge de um PR AI tests no seu branch só depois de ler cada teste.
O problema do oráculo
Um teste gerado verifica o que o código faz, não o que ele deveria fazer. Se o HoldService deixa uma reserva expirar dois minutos antes, o modelo vai escrever com toda a alegria assertThat(hold.expiresAt()).isEqualTo(now.plusMinutes(8)) — e o bug agora está protegido por um teste verde. Testes de mutação provam que os testes são sensíveis, não que estão certos. Leia cada asserção contra o requisito ("uma reserva dura 10 minutos"), não contra o código.
Armadilhas comuns
- Medir testes gerados pela cobertura: a cobertura sobe com testes sem asserções. Use o score de mutação.
- Deixar o gerador editar o código de produção "para o teste passar": aí ele não é um gerador de testes, é uma ferramenta de refatoração sem revisão.
- Rodar o PIT em tudo, em todo PR: minutos e dólares. Mire nas classes alteradas, sob demanda.
Solução de problemas
| Sintoma | Causa | Correção |
|---|---|---|
Could not find a valid Docker environment no CatalogApiTest | o mvn verify também roda o teste com Testcontainers do lab 11, e o Docker não está rodando | inicie o Docker (lab 0); ou rode só o teste unitário: mvn -B -f services/catalog/pom.xml test -Dtest=CatalogServiceTest |
package org.junit.jupiter.api does not exist | falta o spring-boot-starter-test no pom.xml | use o pom.xml do passo 2.2 |
PIT: No test library found ou 0 tests examined | falta a dependência pitest-junit5-plugin, ou -DtargetTests não casa com o nome da classe de teste | mantenha o bloco do plugin do passo 2.2; classes de teste terminam em Test (CatalogService*Test) |
release version 21 not supported / The JAVA_HOME environment variable is not defined correctly | o Maven roda com outro JDK, ou o JAVA_HOME está errado | java -version precisa dizer 21+; defina o JAVA_HOME (macOS: export JAVA_HOME=$(/usr/libexec/java_home -v 21)) |
mvn: command not found durante o gen_tests.py | o Maven não está no PATH deste shell | instale-o (lab 0) ou abra um novo terminal |
Toda tentativa does not compile or fails | o modelo inventa métodos ou esquece imports (comum em modelos locais pequenos) | leia o erro na lista de tentativas; tente AI_TESTS_MAX_ATTEMPTS=5, ou um modelo mais forte; uma rejeição é um resultado válido e registrado |
passes twice; mutation score ... mas não aceito | os testes novos rodam mas matam poucos mutantes a mais (MIN_GAIN = 5 pp) | esperado: não vale a pena mantê-los. Veja os sobreviventes no relatório do PIT |
O job generate estoura o timeout de 60 minutos | runner com CPU + modelo local + PIT | mire uma classe só (head -n 1 no workflow) ou use um modelo hospedado neste job |
Próximo: parte 3 — o agente de correção
-
COLES, H. et al. PIT: a practical mutation testing tool for Java. ISSTA 2016 (demonstração de ferramenta). ↩
-
FOSTER, C. et al. Mutation-Guided LLM-based Test Generation at Meta. FSE 2025 (trilha de indústria). ↩