Case Study12 min read
Tests first : locking down a legacy Spring Boot fleet before migrating it
In April, one service had a Cucumber scenario. By the end of September, 72 of 79 had one, written by the teams as the safety net for Java 21 🥒

This series is one migration : ~80 Spring Boot services on Java 11 and Spring Boot 2, owned by several teams, all moving to Java 21 and Spring Boot 3, ported with AI agents. The agents turned out to be the cheap part. The expensive part is the rails that let a person trust what comes back : tests that pin what each service does today, a paved road that leaves one way to build, and skills that make the agent prove it changed nothing. This post is the first rail, the tests, because that is where the work started, and the only reason the other two hold.
Spring Boot 2’s last open-source release shipped in November 2023.
The day before the testing tutorial merged, 1 service had a Cucumber scenario, and 1 had an integration test. By the end of September, 72 of 79 have Cucumber scenarios and 71 have integration tests. My part was the shape : three layers of tests, Testcontainers instead of embedded databases, AssertJ and BDDMockito in given / when / then, Cucumber on top. I wrote the first suites on a few services, three at least, with AlloyDB and Pub/Sub containers as well as MongoDB, to check that the shape held on real code. Then I turned it into a tutorial and a session for the teams. A teammate packaged those patterns as an agent skill, and the teams pinned their own services with it. Here is how.
“It compiles” is not a migration status
The naive plan for a framework upgrade is familiar : bump the parent, fix what does not compile, deploy, watch the logs. On one service it works. On eighty, it means discovering regressions in production, one team at a time.
Spring Boot 3 is not a version bump. It moves javax to jakarta, removes the Spring Security configuration style most services were written in, drops Spring Cloud Sleuth for Micrometer Tracing, and changes defaults you never knew you depended on. A green build tells you none of that. Neither does a green deploy.
And the services did have tests, of a kind. The build enforced a coverage gate on lines executed, and the tests were written to satisfy the gate. In the shared libraries, some test classes had an empty body. Coverage measured that code ran. It never asked what the code returned.
So before anyone touched a version number, we set one rule :
Pin what the code does today. Migrate second.
Not what it should do. What it does, bugs included. These are characterization tests : their job is to go red the moment the new stack behaves differently from the old one. A bug found on the way is pinned like the rest. Fixing it is a separate change, or the migration loses its baseline.
Three layers, one shape
Every test in the fleet follows the same shape, whatever its layer : // given, // when, // then, AssertJ for the assertions and BDDMockito.given for the stubs. A reader from another team should find the same three blocks in every file. The controller slice, condensed :
@WebMvcTest(OrderController.class)class OrderControllerTest {
@Autowired private MockMvcTester mvc;
@MockitoBean private OrderService service;
@Test void shouldReturnNotFoundForUnknownOrder() { // given given(service.findByOrderId("999")).willReturn(Optional.empty());
// when var response = mvc.get().uri("/orders/v1/orders/999");
// then assertThat(response).hasStatus(HttpStatus.NOT_FOUND); }}The samples in this post are the paved-road form, on Spring Boot 3. The suites were first written on Java 11 and Spring Boot 2, with the older equivalents : MockMvc, @MockBean, a hand-wired container. Same shape, older names.
One convention lives in the service rather than in the tests : a Clock bean. Production wires Clock.systemUTC(), the tests wire Clock.fixed(...), and the code calls Instant.now(clock) instead of Instant.now(). Without it, a timestamp in a response is a value no scenario can assert, and what a scenario cannot assert, it does not pin. It went out as a recommendation in the session, not as a build rule, and the services picked it up.
// production@BeanClock clock() { return Clock.systemUTC(); }
// tests@BeanClock clock() { return Clock.fixed(Instant.parse("2026-07-08T10:15:30Z"), ZoneOffset.UTC); }
// in the service codeInstant.now(clock)Three layers, split by name so that Maven runs them in two lanes :
- Unit and slice tests (
*Test, Surefire) : no Docker, seconds, the fast lane every push runs. - Integration tests (
*IT, Failsafe) : the whole application on a real database in a container, entered one layer at a time. The repository test runs queries, the service test runs the service on top of them, and the controller test goes through MockMvc down to MongoDB. - Cucumber scenarios (also
*IT) : the service booted end to end, driven over HTTP, asserted on the JSON it returns.
The third layer is the one that pins behaviour. The first two make the third one cheap to debug.

Testcontainers, because an embedded database is a different database
The integration tests talk to the real engine or they prove nothing about it : an embedded MongoDB is not MongoDB. With Testcontainers and Spring Boot’s @ServiceConnection, a real one is a bean declaration away :
@TestConfiguration(proxyBeanMethods = false)public class Containers {
@Bean @ServiceConnection // Spring Boot wires spring.data.mongodb.* to the container MongoDBContainer mongoContainer() { return new MongoDBContainer("mongo:8.0"); }}The fleet had one more twist. Some services run Atlas Search queries, the $search aggregation stage, and those only run where Atlas’s search process runs. A plain mongod rejects them. MongoDB publishes that process in the Atlas Local image, and Testcontainers has a module for it, so a test can run the same queries as production, search included. On our Spring Boot 3 version, @ServiceConnection does not know that container yet, so the connection string is wired by hand. The test classes import it with @ImportTestcontainers(Containers.class), which starts the container and picks up the property source :
public interface Containers {
@Container MongoDBAtlasLocalContainer mongo = new MongoDBAtlasLocalContainer("mongodb/mongodb-atlas-local:8.0.26");
@DynamicPropertySource static void mongo(DynamicPropertyRegistry registry) { registry.add("spring.data.mongodb.uri", mongo::getConnectionString); }}The same containers also gave us the local dev loop. The managed MongoDB clusters are not reachable from a laptop, so running a service locally meant installing a MongoDB yourself first. Now spring-boot:test-run boots it from the test sources, so the container and a seeded database come along :
public class RunWithTestcontainers {
public static void main(String[] args) { SpringApplication.from(SampleServiceApplication::main) .with(Containers.class) .withAdditionalProfiles("local") .run(args); }}One command, no cloud account, no shared database to step on. Tests and local runs use the same containers, so they stay in sync.
Gherkin as the contract between teams
A Java test is read by the team that owns it. A feature file is read by everyone who calls the service :
Feature: Orders API
Background: Given The following documents exist in the "orders" collection: | orderId | amount | | 1 | 42 | | 2 | 7 |
Scenario: Get one order by its identifier When I send a GET request to "/orders/v1/orders/2" Then the response status is 200 And the response json path "$.amount" is "7"
Scenario: Unknown orders are a 404, in the platform's error shape When I send a GET request to "/orders/v1/orders/999" Then the response status is 404 And the response content type is "application/problem+json"The steps are generic : seed a collection, send a request, assert a JSON path. Every service uses the same sentences, so a scenario written by one team reads the same in every other team’s repository. The step library started as a copy in each service and ended up as a test dependency. That move belongs to the next post.
The boring guards : format, quality, architecture
Three more tools went in with the tests, all chosen to remove arguments from code review :
- Spotless with google-java-format checks the formatting at
compile. Formatting is applied by a command, not discussed in a review. When the migration diffs came, they contained the migration and nothing else. - Checkstyle keeps only what a formatter cannot fix : no
System.out, noprintStackTrace(), no emptycatchwithout a comment saying why, noequalswithouthashCode. The formatting rules it used to carry are gone, and so is the IDE setup they required. Both tools run in the Maven build, so there is nothing to install in the IDE. - ArchUnit turns the conventions into tests. Our conventions used to live in a wiki page, which nothing checks 🥱 Now a controller that reaches into a repository fails the build, like any other test.
The onboarding these tools replaced, and the shared ArchUnit rule set that grew out of real incidents, are in the paved-road post.
A tutorial in six commits
I wrote the first suite on one real service, as a pull request whose commits are the tutorial :
- A first test on the shared utility library every service depends on, and the fixes it needed
- A unit test sample, given / when / then
- A mock test with
BDDMockito - The first Cucumber scenario, on the actuator health endpoint
- Surefire and Failsafe split : unit tests and
*ITin two lanes - The Cucumber HTML report in the build

Each commit is small enough to review on its own, and each one is a step another engineer can copy into their service. The PR merged in mid-April. In July I gave the same material as a session to all the data engineering teams, From red to green : the stack, the three layers, why each tool is there and the conventions it replaces.
Then the teams took it, with an AI agent
Writing characterization tests for eighty services by hand is weeks of reading old code. My teammates did not do it by hand.
A teammate packaged the tutorial’s patterns as an agent skill : the dependencies, the Failsafe setup, the test profile, the fixture layout, the Cucumber runner, and the mistakes reviewers kept flagging. With it, an AI agent reads a legacy service, its endpoints and its data, writes the fixtures and the scenarios, and runs them against the service as it is. Passing against the old code is the whole point : a scenario that passes on Java 11 describes what the service does today.
Pinning means pinning the odd parts too. Many of the services read their request body as a raw String, so malformed JSON gets past Spring and fails later, inside the service code, as a 500 instead of a 400. The skill tells the agent to assert the 500 as it is. When the agent finds a real flaw on the way, it leaves a // TODO: for the owning team and does not fix it.
What makes this safe is the shape around the agent. The steps are a fixed vocabulary, so a generated scenario reads like a hand-written one. The assertions are on HTTP responses and stored documents, so they cannot quietly test the mocks. One trap the skill warns about shows why : a request body written with the Java field names instead of the JSON keys deserializes to nulls, and a unit test with a mocked service and any() matchers still passes. A scenario that runs the real code fails. Every suite arrives as a pull request and runs in CI against the old stack.
What a green suite proves
Not that the service is correct, and not that every behaviour is pinned. A green suite says that the behaviours its scenarios assert still match the old stack, and it is worth exactly what those scenarios assert. An agent lowers the cost of writing them, and the cost of writing weak ones too : a scenario that checks a status code and nothing else pins almost nothing. That is why the pull request is the step that matters. The agent produces the evidence. The team that owns the service decides whether it is enough, before the migration rests on it.
The numbers
Counted on the default branch of every Spring Boot service, as of 2026-09-30. For the eight pilot services that shaped the migration, the tests were written first and the upgrade followed on the same Java 21 branch : they are counted on that branch.
- 79 Spring Boot services in the fleet
- The day before the tutorial merged, 70 of them existed : 29 had test code of any kind, 1 had an integration test, 1 had a Cucumber scenario
- End of September : 76 of 79 have tests (96%), 71 have integration tests and 72 have Cucumber scenarios (91%)
- At least 51 start a real MongoDB in a container for their tests
- From March to September : 1,429 Cucumber scenarios added, net of those removed, 1,159 of them by 13 teammates (81%) in 66 services

Four Cucumber scenarios out of five were written by other people. I set the shape and wrote the first suites. A teammate turned them into an agent skill. The teams pinned their own services. That was the plan from the first commit : eighty suites were never a job for one person, so the job was a way of writing one that any team could repeat.
Next in the series
This is the first part of Modernizing a Spring Boot fleet with AI agents, on rails. With the baseline pinned, the next question was what the eighty services should migrate to. Next post : the paved road, a parent, a BOM and a few starters that turn eighty dependency trees into one, with a public version you can run, tests included 🧱



