CodeGym /Kursy /Spring Test /Migration of MockMvc to MockMvcTester

Migration of MockMvc to MockMvcTester

Spring Test
Poziom 11, Lekcja 1
Dostępny

1. Migration as refactoring

When raw MockMvc already works and the tests reliably protect the HTTP contract, the next practical question is very down to earth: how do you move to MockMvcTester without losing that protection. The most common mistake here is not in the new API, but in the desire to rewrite everything in one pass — along with the URI, mocks, JSON, and expectations.

So it is useful to think about migration as ordinary test refactoring. Refactoring means "we change the form of the code without changing its meaning." In our case, the "meaning" is the HTTP contract that we are fixing: which endpoint, which method, which headers, which status, which fields in the JSON. And the "form" is the concrete syntax of the check: .andExpect(...) versus .hasStatusOk().

To keep yourself in check (and not turn migration into a creative performance), it is helpful to fix what exactly we preserve and what we really change:

Test axis What stays unchanged What changes during migration
Test level @WebMvcTest, narrow MVC slice, "one controller — one test class" Nothing
Controller dependencies the same @MockitoBean, the same stubbing given(...).willReturn(...) Nothing
HTTP scenario the same URI, the same HTTP method, the same Accept, the same Content-Type, the same request body Nothing
Checks the same expectations for status, headers, and JSON only the assertion style: AssertJ chains instead of .andExpect(...)

This separation is important psychologically. When you migrate tests to MockMvcTester, you are not "reworking the test," you are translating it into another dialect while preserving the plot. If the plot changed, then it is no longer migration, but rewriting the scenario (and the scenario in your case is the API contract).

2. Side-by-side strategy

A "straightforward" migration breaks the test package not because MockMvcTester is bad, but because a person changes too much at once: rewrites the request, fixes the mocks, improves the names, and at the same time "slightly simplifies" the assertions — and now it is no longer clear where the contract got lost. That is why the safest rule sounds boring, but it always works: one scenario — one axis of change.

In practice, it looks like this: you take one stable test scenario (usually a simple GET), create a version with MockMvcTester next to it, run the tests, and compare. If the behavior did not change, you remove the old form (or keep it for the migration period if you work on a branch and want to see the comparison). And only then do you move to the next scenario.

Visually, "how to migrate without pain" can be represented like this:

flowchart TD
    A[There is a green raw MockMvc test] --> B[Copy the scenario next to it]
    B --> C[Do not touch Arrange: mocks/data]
    C --> D[Do not touch Act: URI, headers, body]
    D --> E[Change only Assert: andExpect -> AssertJ]
    E --> F{Are the tests green?}
    F -->|Yes| G[Remove the old form or record the migration with a commit]
    F -->|No| H[Compare: where did the HTTP detail get lost?]

An important bonus: MockMvcTester specifically supports "bridges" for gradual migration. If you already have RequestBuilders from the old API or you are used to certain ResultMatchers, you can temporarily keep them and change only the assertions. This is not a "hack"; it is a normal strategy for a real project.

3. Migrating a GET scenario: GET /api/public/articles/{slug}

The best place to start migration is the calmest scenario: GET without a body, but with an explicit Accept and a JSON response. In ContentHub, the ideal candidate is the public article card by slug. We mock the service layer (as is appropriate for @WebMvcTest), and the test fixes that the HTTP boundary behaves as promised: correct status, correct Content-Type, correct fields in JSON.

Bilingual test class

During migration, it is convenient to keep raw MockMvc and MockMvcTester side by side. Not to mix them in one test, but so that you can rewrite tests one by one and compare the approaches in the same environment.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {

    @Autowired MockMvc rawMvc;     // Old "console": raw MockMvc API
    @Autowired MockMvcTester mvc;  // New "console": AssertJ style on top of the same MVC context

    @MockitoBean ArticleQueryService articleQueryService; // Mock the service layer so the test is about the HTTP boundary
}

In this scenario, MockMvcTester is just another "control panel" for the same MVC context: the request is executed through MockMvc, and the result is available through AssertJ assertions and the MvcTestResult object.

Before: raw MockMvc

The old test usually looks something like this: "perform GET, set Accept, check 200."

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test; 
import org.springframework.http.MediaType;

@Test
void shouldReturnArticleDetails_rawMockMvc() throws Exception {
    // Arrange: the service layer is mocked so the test fixes only the controller behavior
    given(articleQueryService.findBySlug("spring-basics"))
            .willReturn(new ArticleDetailsResponse("spring-basics", "Spring Basics"));

    // Act + Assert: check the HTTP contract (URI + Accept + status)
    rawMvc.perform(get("/api/public/articles/spring-basics")
            .accept(MediaType.APPLICATION_JSON)) // It is important to explicitly fix Accept
        .andExpect(status().isOk()); // Expected status by contract
}

Notice: the meaning of the test here is correct. It is not trying to check "how the service found the article"; it checks that the controller at the HTTP level returns the expected status for the expected URL.

Now: MockMvcTester

Now we rewrite only the assertions. The Arrange part (given(...)) stays the same, the URI and Accept stay the same, the status expectation stays the same.

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;

import org.junit.jupiter.api.Test; 
import org.springframework.http.MediaType;

@Test
void shouldReturnArticleDetails_mockMvcTester() {
    // Arrange: do not touch mocks and fixtures during migration
    given(articleQueryService.findBySlug("spring-basics"))
            .willReturn(new ArticleDetailsResponse("spring-basics", "Spring Basics"));

    // Act + Assert: only the style of checks changes (AssertJ chain)
    assertThat(mvc.get()
            .uri("/api/public/articles/spring-basics") // Fix the exact URI
            .accept(MediaType.APPLICATION_JSON))       // Fix the exact Accept
        .hasStatusOk(); // Check exactly 200 OK
}

The key effect of migration is visible right away: the test no longer requires throws Exception, and the "visual noise" from the .andExpect(...) chain disappears. At the same time, the HTTP semantics were not lost: uri(...) and accept(...) are still visible, and anyone reading the test sees the contract with their own eyes.

JSON checks in MockMvcTester

In the next migration step, you usually want to bring back the same response-body checks. In raw MockMvc, that is often jsonPath(...). In MockMvcTester, instead there is bodyJson() with support for extraction by JsonPath and further AssertJ checks.

Raw variant:

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;

rawMvc.perform(get("/api/public/articles/spring-basics")
        .accept(MediaType.APPLICATION_JSON))
    .andExpect(status().isOk())
    // Check a specific field in the JSON response
    .andExpect(jsonPath("$.slug").value("spring-basics"));

Variant with MockMvcTester:

import static org.assertj.core.api.Assertions.assertThat;

assertThat(mvc.get().uri("/api/public/articles/spring-basics")
        .accept(MediaType.APPLICATION_JSON))
    .hasStatusOk()
    .bodyJson() // Move to the JSON response body
    .extractingPath("$.slug").asString() // Extract the value by JsonPath and cast the type
    .isEqualTo("spring-basics"); // Check the specific expected value

There is an important "psychological" win here: you stayed in a single AssertJ style. You do not need to jump between AssertJ and Hamcrest matchers, and the test visually reads as one coherent scenario. And JsonPathValueAssert has clear conversions like .asString(), .asNumber(), .asMap() — this reduces the pain of "what type did JsonPath return?"

Long checks: exchange()

That is enough while the test checks one idea. But as soon as you need to keep track of status, Content-Type, several JSON fields, or an error payload at the same time, one long fluent chain starts to get in the way no less than the old .andExpect(...).

At that moment, it is useful to switch to exchange(), get MvcTestResult, and split the assertions into semantic groups. The same stub articleQueryService.findBySlug(...) is assumed as in the example above; here, not the scenario changes, but the form of the checks.

import static org.assertj.core.api.Assertions.assertThat;

import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.assertj.MvcTestResult;

MvcTestResult result = mvc.get()
        .uri("/api/public/articles/spring-basics")
        .accept(MediaType.APPLICATION_JSON)
        .exchange();

assertThat(result).hasStatusOk();
assertThat(result).hasContentType(MediaType.APPLICATION_JSON);
assertThat(result).bodyJson().extractingPath("$.slug").asString().isEqualTo("spring-basics");
assertThat(result).bodyJson().extractingPath("$.title").asString().isEqualTo("Spring Basics");

The same pattern is convenient for an error payload as well: fix the status and response format separately, and then one or two key error fields separately. A short chain is good while the test holds one idea. As soon as the response is multi-part, exchange() usually makes the test more readable and the failure more understandable.

4. Migrating POST: POST /api/editor/articles

After GET, you usually want to make sure that the migration does not break on "real" requests, where there is a request body and where you need to strictly fix the Content-Type. In ContentHub, this is the article draft creation scenario: POST /api/editor/articles. Here it is especially easy to accidentally lose an HTTP detail: for example, forget Content-Type, change the JSON body, or start checking "some 2xx" instead of the exact 201.

Raw MockMvc: 201 + Location

First we fix a clear raw scenario. In it, the request body is part of the contract, so we do not hide it "for beauty".

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.springframework.http.MediaType;

// JSON is part of the contract: let it be clearly visible in the test
String json = """
        {"title":"T","summary":"S","body":"B","category":"java"}
        """;

rawMvc.perform(post("/api/editor/articles")
        .contentType(MediaType.APPLICATION_JSON) // Important: fix the request Content-Type
        .accept(MediaType.APPLICATION_JSON)      // Important: fix the response Accept
        .content(json))                          // Important: fix the request body
    .andExpect(status().isCreated())             // Expect exactly 201
    .andExpect(header().exists("Location"));     // Contract: Location is required

Yes, this is a bit verbose. But it very clearly shows: which endpoint, which Content-Type, which Accept, which body, and what we expect in the response.

MockMvcTester: the same expectations

Now the same scenario in MockMvcTester. Notice: we check 201 not through "OK", but through the explicit HttpStatus.CREATED. MockMvcTester has hasStatusOk(), but that is exactly 200 OK, and you cannot use it in place of "Created" (otherwise you would make your own contract worse).

import static org.assertj.core.api.Assertions.assertThat;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;

// The same JSON, because migration is a refactoring of form, not a change in contract
String json = """
        {"title":"T","summary":"S","body":"B","category":"java"}
        """;

assertThat(mvc.post()
        .uri("/api/editor/articles")             // The same endpoint
        .contentType(MediaType.APPLICATION_JSON) // Fix the request Content-Type
        .accept(MediaType.APPLICATION_JSON)      // Fix Accept
        .content(json))                          // Fix the request body
    .hasStatus(HttpStatus.CREATED)               // Check exactly 201 Created
    .containsHeader("Location");                // Contract: the Location header must be present

The containsHeader("Location") method is the very same "analog" of header().exists(...) from raw MockMvc, only in AssertJ style.

Discipline: do not touch mock setup

At this point, many people (especially after a couple of successful rewrites) start to "improve everything at once". For example, together with migration they change the stubbing: replace given(...).willReturn(...) with when(...).thenReturn(...), rename the service, touch the DTO. This is the case where tests start to look like a kitchen renovation: you wanted to replace the faucet, and suddenly you are lying among the tiles because "since we already started".

If you want safe migration, keep one rule: when translating raw MockMvc → MockMvcTester, you change only how the request is executed and how assertions are written. You leave the Arrange part (mocks, fixtures, data) as it is. Then any red check will mean: you lost an HTTP detail, not that you accidentally changed the business meaning.

5. Bridge: perform(...) and matches(...)

Sometimes tests already have infrastructure around them: you have your own RequestBuilders, your own ResultMatchers, or familiar pieces like status().isOk(). Rewriting everything to the new style at once is expensive and risky. That is why MockMvcTester has a built-in "move without panic" strategy: you can execute a request through perform(...) and even use the old matchers via .matches(...).

Example where you keep the old RequestBuilder, but already move to AssertJ assertions:

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

import org.junit.jupiter.api.Test; 

@Test
void shouldReuseOldRequestBuildersDuringMigration() {
    // "Bridge": execute the request with the old RequestBuilder, but check the result in the new style
    assertThat(mvc.perform(get("/api/public/articles")))
            .hasStatusOk();
}

And here is an example where you even keep the old ResultMatcher (for example, because you have lots of andExpect(status().isOk()), and you want to migrate gradually):

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// Bridge on both sides: both RequestBuilder and ResultMatcher remain old,
// while the "wrapper" around them is already MockMvcTester
assertThat(mvc.perform(get("/api/public/articles")))
        .matches(status().isOk());

It is useful to think of this as an intermediate stop. In the end, you will probably want to move to the more "native" style mvc.get().uri(...), but the "bridge" path allows you not to break the test package and not to give yourself a weekend investigating "why is half the class failing now."

6. Typical mistakes when migrating to MockMvcTester

Migration is that rare case where you can make a test shorter and nicer, but at the same time accidentally make it weaker. The mistakes here are almost always not in Spring or Mockito, but in human haste: we "sort of" rewrote the same thing, but missed a small HTTP detail, which was exactly the point of the test. Below are the pitfalls people step on most often.

Mistake #1: rewrite the style and change the scenario at the same time.
The most common failure looks like this: "I rewrote the test to MockMvcTester, and while I was at it, I decided that Accept can be removed, and anyway it is better to call the endpoint through a helper." As a result, the test is green, but it no longer proves the same thing. During migration, keep the iron rule: URI, method, headers, body, and the meaning of the assertions stay the same. Only the syntax of the check changes.

Mistake #2: "hide HTTP for the sake of beauty".
MockMvcTester really does let you write more compactly. But if, for the sake of brevity, you stopped explicitly specifying accept(MediaType.APPLICATION_JSON) or stopped fixing contentType(MediaType.APPLICATION_JSON), you made the test less honest: now it checks "something worked," not a specific contract. In our course, we deliberately learned not to leave such things "by default" — do not cancel that rule just because the API became prettier.

Mistake #3: mix raw MockMvc and MockMvcTester in one test method.
Technically, you can keep both MockMvc and MockMvcTester in the class (this is convenient for migration), but inside one method you should choose only one. Otherwise, you get a strange mess: Act is done with one API, Assert with another, and a week later you will not remember why it was done that way. If you keep both fields, use them in different test methods.

Mistake #4: make chains too long and hard to debug.
assertThat(mvc.get(...)...) with a chain of .hasStatusOk().hasContentTypeCompatibleWith(...).bodyJson()... looks great while the chain is short. As soon as you "grow" the checks, use exchange() and several separate assertThat(result).... This does not make the test worse; on the contrary, it becomes more readable, and failures are easier to diagnose.

Mistake #5: replace precise checks with overly broad ones.
Sometimes during migration a person thinks: "Well, I am checking 2xx anyway, so it is fine." And they put hasStatus2xxSuccessful() instead of hasStatus(HttpStatus.CREATED). As a result, the test stops catching changes in the contract. In web-layer tests, the precision of statuses and headers is part of the value — do not throw it away.

1
Zadanie
Spring Test, poziom 11, lekcja 1
Niedostępne
Jeden GET-scenariusz w dwoch stylach
Jeden GET-scenariusz w dwoch stylach
1
Zadanie
Spring Test, poziom 11, lekcja 1
Niedostępne
Jeden POST-scenariusz w dwóch stylach
Jeden POST-scenariusz w dwóch stylach
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION