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 :

OrderControllerTest.java
@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.

ClockConfiguration.java
// production
@Bean
Clock clock() { return Clock.systemUTC(); }
// tests
@Bean
Clock clock() { return Clock.fixed(Instant.parse("2026-07-08T10:15:30Z"), ZoneOffset.UTC); }
// in the service code
Instant.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.

What each kind of test touches, against the layers of a service : unit and slice tests cover the controller or the service, one at a time, with the rest mocked ; integration tests enter at the controller, the service or the repository and run down to a real MongoDB in a container ; Cucumber scenarios cover the whole service, from the HTTP request down to MongoDB

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 :

Containers.java
@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 :

Containers.java (Atlas Local variant)
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 :

RunWithTestcontainers.java
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 :

service.feature
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, no printStackTrace(), no empty catch without a comment saying why, no equals without hashCode. 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 :

  1. A first test on the shared utility library every service depends on, and the fixes it needed
  2. A unit test sample, given / when / then
  3. A mock test with BDDMockito
  4. The first Cucumber scenario, on the actuator health endpoint
  5. Surefire and Failsafe split : unit tests and *IT in two lanes
  6. The Cucumber HTML report in the build

The Cucumber HTML report the build produces, here for the public sample service of the next post : 5 of 5 scenarios passed in 6.26 seconds, the two feature files expanded, Actuator with the health and unknown-endpoint scenarios, Orders API with the list scenario, every step green with its duration

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

Cucumber scenarios added per month, net of those removed, from March to September 2026, with the number of contributors under each month : 3 by 1 contributor in March, 63 by 4 in April, the month the tutorial merged on April 17, 263 by 7 in May, 469 by 6 in June, 192 by 6 in July, 109 by 5 in August and 330 by 8 in September. 1,429 scenarios by 14 contributors in total

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 🧱

Share

Related articles