I removed the save from each of Petclinic’s five write endpoints. Its own controller tests caught 0 of 5. Tests that drove the running application over HTTP caught 5 of 5.

That happened on Spring Boot 2.7.3 and again on 4.1.2. The upgrade didn’t create the gap. It was already there in the starting application.

The starting point was the public Spring Petclinic sample at commit 9ecdc11, with Boot 2.7.3 and Java 8 in the POM. Before upgrading, an agent wrote HTTP tests. I checked those tests by removing the saves they were supposed to cover.

The handler still redirects without saving

This is the owner creation handler:

@PostMapping("/owners/new")
public String processCreationForm(@Valid Owner owner, BindingResult result) {
    if (result.hasErrors()) {
        return VIEWS_OWNER_CREATE_OR_UPDATE_FORM;
    }
    this.owners.save(owner);
    return "redirect:/owners/" + owner.getId();
}

The mutation removes only this.owners.save(owner);. Validation and the redirect remain.

Petclinic’s controller test uses @WebMvcTest(OwnerController.class) and @MockBean private OwnerRepository owners;. Later in the upgrade, that becomes @MockitoBean.

Here is its creation test:

@Test
void testProcessCreationFormSuccess() throws Exception {
    mockMvc.perform(post("/owners/new").param("firstName", "Joe").param("lastName", "Bloggs")
            .param("address", "123 Caramel Street").param("city", "London").param("telephone", "01316761638"))
            .andExpect(status().is3xxRedirection());
}

The assertion checks for a redirect. It never reads the owner back.

With the repository mocked, the save is a no-op either way. Removing the call doesn’t remove the return statement. The status assertion still passes.

Write through HTTP, then read through HTTP

The added tests start the real application on a random port, use the real database, and send requests with TestRestTemplate.

This is the owner creation test from the run:

@SpringBootTest(classes = PetClinicApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OwnerControllerHttpTests {

    @Autowired
    private TestRestTemplate client;

    @Test
    void shouldPersistOwnerWhenCreationFormIsValid() {
        String marker = UUID.randomUUID().toString();
        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
        form.add("firstName", "Httpfirst");
        form.add("lastName", "Httpcreatedowner");
        form.add("address", marker);
        form.add("city", "Madison");
        form.add("telephone", "6085551023");
        client.postForEntity("/owners/new", encoded(form), String.class);

        ResponseEntity<String> response = client.getForEntity("/owners?lastName=Httpcreatedowner", String.class);
        assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
        assertThat(response.getBody()).contains(marker);
    }
}

Boot 4 needs a moved import and @AutoConfigureTestRestTemplate as well.

The UUID goes into the address submitted to /owners/new. The test then requests /owners?lastName=Httpcreatedowner and looks for that exact address marker.

The fresh marker matters. A response containing a familiar owner name could be showing seeded data. Here the value being checked came from this request.

The separate GET matters too. The assertion checks what the app can read after the write. A successful POST response alone would leave the missing-save problem untested.

Five mutations on each version

For each endpoint, I used a separate copy of the project with only its save line removed. Each copy ran the whole suite:

./mvnw test

Every handler in this experiment ends with this.owners.save(owner);, including the pet and visit writes.

Save removed fromBoot 2.7.3 HTTP test failuresBoot 4.1.2 HTTP test failuresPetclinic’s own tests failed
POST /owners/new110
POST /owners/{ownerId}/edit220
POST /owners/{ownerId}/pets/new110
POST /owners/{ownerId}/pets/{petId}/edit110
POST /owners/{ownerId}/pets/{petId}/visits/new110

Both unbroken baselines passed. Both versions had 52 tests in the suite, with 1 skipped.

Every broken copy failed, and only the HTTP tests for the affected endpoint failed. The owner edit mutation produced two failures because separate tests checked the changed address and changed city.

The tests caught 5 of 5 broken endpoint cases, with 1, 2, 1, 1, and 1 failures on each version.

Put these tests in place before the upgrade

Start with the write endpoints your app already has. In this sample, that meant owner creation and editing, plus pet creation and editing and adding a visit.

For a create endpoint, send a fresh unique value through the real request path. Read it through a different GET in the running app. Assert the exact value you submitted.

For an update, make the assertion depend on the changed value. Reading back a value that was already present in the setup doesn’t check the update. The separate address and city tests in this run both failed when the owner edit save disappeared.

For a delete endpoint, seed the record, see it through the app, delete it, then see it gone.

Run the unmodified app first. Then remove one save in a disposable copy and run the suite again. Check which test failed and where. A failure while loading the application context doesn’t show that the request reached the handler.

Restore the save before continuing. Repeat for the next write endpoint. That gives you a direct check of the assertion you plan to rely on during the upgrade.

Keep the same assertions through the version changes

These HTTP tests were written once, before the upgrade, and ran after every step from 2.7.3 through 4.1. The assertions didn’t change. Boot 4 required an import move and one annotation.

They also caught the Boot 3.4 HTTP client redirect change. That failure had a different cause: the test client followed the POST redirect, so an assertion expecting 3xx saw the next page’s 200. The test-side migration errors explain the verified property fix.

Keeping the assertions stable lets you compare behavior across the full upgrade path. If you rewrite what the test accepts during the same step, you have another change to explain.

The tests in this experiment came from an agent. Removing the save and watching the matching test fail showed that they checked the write. Checking generated tests covers the weak assertions that were refused before tests were kept.

Java Modernization Readiness Assessment

15 questions your team should answer before starting a migration. Takes 10 minutes. Could save you months.