c. AI Tests
A model can write fifty unit tests in a minute. The hard question is not can it write tests but are these tests worth keeping? Coverage does not answer it: a test that calls every method and asserts nothing reaches 100 % line coverage and catches zero bugs. This part uses mutation testing as the judge: a generated test is kept only if it catches bugs that the existing tests miss.
2.1 Mutation testing in one paragraph
A mutation-testing tool makes small changes to the production code β each one a mutant: > becomes >=, a * b becomes a / b, a return price becomes return 0, a method call is removed β and runs the tests against each mutant. If some test fails, the mutant is killed (the tests noticed the bug); if all tests still pass, it survived (a bug the tests cannot see). The mutation score is killed Γ· total. PIT is the standard tool for the JVM1.
flowchart LR
pr["PR changes<br/>CatalogService.java"] --> base["PIT with existing tests<br/>baseline score"]
base --> gen["Model writes<br/>CatalogServiceAiTest"]
gen --> comp{"compiles and<br/>passes twice?"}
comp -->|"no: errors back"| gen
comp -->|"yes"| pit["PIT with existing + new tests"]
pit --> gain{"score β₯ baseline + 5 pp?"}
gain -->|"no: survivors back"| gen
gain -->|"yes"| pr2["Draft PR with the test"] The loop gives the model three kinds of feedback, in order: compiler errors, failing tests, and the list of surviving mutants ("line 42: changed conditional boundary in isExpired()"). That last one is the interesting part: it tells the model exactly which behaviour is untested, the same idea behind mutation-guided test generation reported at Meta2.
2.2 Add unit tests and PIT to catalog
The generator needs a service with test libraries and PIT. Lab 11 already gave catalog its test dependencies (the Spring Boot test starters, which bring JUnit Jupiter, Mockito and AssertJ, plus Testcontainers for the integration test). Lab 13 adds one block, marked lab 13 in the file: the PIT plugin with its JUnit 5 adapter, which also runs the JUnit 6 tests of Spring Boot 4. PIT runs only when you call its goal, never in a plain mvn verify. Replace the file β it is lab 11's pom.xml plus that block:
<?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>
And a first, deliberately incomplete, hand-written test of CatalogService β plain JUnit + Mockito, no Spring context and no database, so it runs in a second:
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);
}
}
Checkpoint. From the repository root:
verify runs both test classes of catalog: the integration test CatalogApiTest of lab 11 (Testcontainers: Docker must be running) and the new CatalogServiceTest. You should see, near the end (the first run downloads the test libraries and the postgres:17 and redis:8-alpine images; the times vary):
[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
A warning Mockito is currently self-attaching to enable the inline-mock-maker is harmless. Docker not running right now? Run only the new unit test, which needs no container: mvn -B -f services/catalog/pom.xml test -Dtest=CatalogServiceTest prints Tests run: 3, Failures: 0, Errors: 0, Skipped: 0. PIT (below) and the generator also run only unit tests, so they work without Docker.
Now run PIT by hand once on one class to see the report (HTML in 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'
You should see, near the end (a run on Java 21 while this lab was written; the timings depend on your machine, the counts do not):
================================================================================
- 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
Read it: PIT made 18 mutants of CatalogService; the three tests of CatalogServiceTest killed 4 (a mutation score of 22 %). Fourteen mutants are in code that no test even executes (findEvent, upcomingEvents, createEvent, ...). Open the HTML report: each red line is a bug your tests would not notice. That gap is what the generator must close.
Keep PIT targeted
Mutation testing is expensive: every mutant runs (part of) the test suite. On a whole service it can take many minutes. Always target the classes the PR changed (-DtargetClasses) β that is exactly what the script does.
2.3 The generator
| .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 | |
| Rule in the code | Why |
|---|---|
New file <Class>AiTest.java, never edits <Class>Test.java | Generated tests never overwrite human ones; review sees exactly what is new |
FORBIDDEN constructs | No Thread.sleep (slow and flaky), no @SpringBootTest (slow, needs a DB), no @Disabled (a disabled test is a lie) |
| Same-package sources in the prompt | To mock EventRepository the model needs its real method signatures, otherwise the test does not compile |
| Runs the test twice | A test that passes once and fails once is worse than no test |
env without KEY/TOKEN/SECRET | The code under test, and the generated test, never see the API key |
MIN_GAIN of 5 percentage points | A test that adds nothing measurable is noise to maintain |
| Deletes the file if rejected | Nothing unproven is left in the working tree |
| One line per attempt in the summary | You can see why an attempt was rejected: compile error, failing test, or too few mutants killed |
2.4 Try it locally
Commit part 2 on main first (the workflow of 2.5 is part of it β save it now), so that the generator starts from a clean tree:
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
Then run the generator on CatalogService, from the repository root. It needs mvn and JDK 21 on your machine, and calls the model up to three times; with the local model expect 5β15 minutes:
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
The summary compares the mutation score with and without the generated class, and says why each attempt was accepted or rejected. A run with the local qwen2.5-coder-16k while this lab was written:
### 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) |
Rejected β and that is the generator working, not failing. The free 7B model invented methods (cannot find symbol) or wrote tests that throw; a second run with AI_TESTS_MAX_ATTEMPTS=5 failed the same way five times (21,278 input tokens, ~16 minutes). Nothing unproven was left behind: git status shows no CatalogServiceAiTest.java. A stronger model (Claude, or a larger local model if your machine has the memory) has a better chance to compile β and then still has to beat the 22 % baseline by 5 pp; compare them in part 7. If yours is accepted, the second row shows the new score and git status lists the new test file.
Record your numbers β model, attempts, baseline and final score, minutes β for part 7.
2.5 The workflow
Generating tests for every PR would be slow and expensive. The workflow runs on demand: add the label ai-tests to a PR. It has two jobs: generate runs the model and the build with a read-only token, and publish β the only job that can write β applies the patch and opens a draft PR. The job that executes untrusted code (the PR's code and the model's code) never holds a write token.
The job picks at most two classes added or modified by the PR under src/main/java. Open a small PR that touches CatalogService β a one-line class comment is enough:
-@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
You should see the generate job finish (20β45 minutes with the free model on a CPU runner: the model, two Maven builds per attempt and PIT) and then either:
- a draft PR
AI tests for #N, labelai-generated, based oncatalog-docs, with one commit carrying the trailerAI-assisted: gen_tests.py (...)β check it withgh pr list --label ai-generated; or publishskipped, and the job summary ofgeneratelisting the rejected attempts. That is a valid result: write down why.
Merge an AI tests PR into your branch only after reading every test.
The oracle problem
A generated test asserts what the code does, not what it should do. If HoldService lets a hold expire two minutes early, the model will happily write assertThat(hold.expiresAt()).isEqualTo(now.plusMinutes(8)) β and the bug is now protected by a green test. Mutation testing proves the tests are sensitive, not that they are right. Read each assertion against the requirement ("a hold lasts 10 minutes"), not against the code.
Common pitfalls
- Measuring generated tests by coverage: coverage rises with assertion-free tests. Use the mutation score.
- Letting the generator edit production code "to make the test pass": then it is not a test generator, it is an unreviewed refactoring tool.
- Running PIT on everything on every PR: minutes and dollars. Target the changed classes, on demand.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Could not find a valid Docker environment in CatalogApiTest | mvn verify also runs the Testcontainers test of lab 11, and Docker is not running | start Docker (lab 0); or run only the unit test: mvn -B -f services/catalog/pom.xml test -Dtest=CatalogServiceTest |
package org.junit.jupiter.api does not exist | spring-boot-starter-test missing from the pom.xml | use the pom.xml of step 2.2 |
PIT: No test library found or 0 tests examined | the pitest-junit5-plugin dependency is missing, or -DtargetTests does not match the test class name | keep the plugin block of step 2.2; test classes end in Test (CatalogService*Test) |
release version 21 not supported / The JAVA_HOME environment variable is not defined correctly | Maven runs on another JDK, or JAVA_HOME is wrong | java -version must say 21+; set JAVA_HOME (macOS: export JAVA_HOME=$(/usr/libexec/java_home -v 21)) |
mvn: command not found during gen_tests.py | Maven not on the PATH of this shell | install it (lab 0) or open a new terminal |
Every attempt does not compile or fails | the model invents methods, or forgets imports (common with small local models) | read the error in the attempts list; try AI_TESTS_MAX_ATTEMPTS=5, or a stronger model; a rejection is a valid, recorded result |
passes twice; mutation score ... but not accepted | the new tests run but kill too few extra mutants (MIN_GAIN = 5 pp) | expected: they are not worth keeping. Look at the survivors in the PIT report |
The generate job hits its 60-minute timeout | CPU runner + local model + PIT | target one class (head -n 1 in the workflow) or use a hosted model for this job |
Next: part 3 β the fix agent
-
COLES, H. et al. PIT: a practical mutation testing tool for Java. ISSTA 2016 (tool demo).Β β©
-
FOSTER, C. et al. Mutation-Guided LLM-based Test Generation at Meta. FSE 2025 (industry track).Β β©