1. Introduction
Once the infrastructural effects of a request are clear, the next good stress test for the same MVC style is binding and conversion. Here the problem appears even earlier: the service may be perfectly written, but the request simply will not reach it if the string parameters were not bound or converted.
When you write a controller, it often seems that the most important thing is the business logic in the service, and the request parameters will "somehow" arrive. But in reality, the clients of your API, including the frontend, a mobile app, and even a colleague's Postman, live in a world of strings: ?page=0&size=10&sort=publishedAt,desc. If binding and conversion do not work the way you expect, your beautiful service will not even be called — the request will "fail" earlier, at the edge of the web layer.
And that is exactly why, in @WebMvcTest, it makes sense to check not only "200 OK", but also that the parameters were correctly bound and turned into the required Java types. This is not business logic testing; it is testing that the HTTP input really goes through the door of your controller instead of hitting the "closed for technical reasons" sign head-on.
So here we are interested in a narrow route: HTTP string -> binding/conversion -> service call or rejection right at the entry point. We do not go into business logic; we verify that the web layer understands the request honestly.
2. Binding in Spring MVC
Binding in the context of Spring MVC is the process where Spring takes data from an HTTP request and inserts it into the parameters of your controller method. This is not one "magic call" but a whole chain: Spring reads the URL, query params, path variables, headers, request body, and tries to assemble values of the types you specified in the method signature.
To make it simpler, imagine that a controller method is a function with parameters, and an HTTP request is an envelope with slips of paper. Binding is the moment when Spring takes the slips out and neatly places them into the right slots. If one slip says "page = kitty", and you expect int page, Spring will honestly say "I cannot do that", and your code will not even be reached.
Below is a simplified diagram of where this step sits in request processing:
flowchart TD
%% Simplified request processing chain in Spring MVC
A[HTTP request] --> B[Spring MVC]
B --> C[Binding: find parameter values]
C --> D[Conversion: String -> required Java type]
D --> E[Call controller method]
E --> F[Controller calls service]
F --> G[HTTP response is formed]
Pay attention to an important idea: conversion is part of binding. First, Spring finds string values (for example, "10"), and then it tries to turn them into int, long, enum, Sort, Pageable, and so on.
3. Conversion: strings to Java types
Conversion is the place where a Java developer usually says, "Well, Spring is smart." And Spring really is smart, but it is not telepathic. It can convert a lot out of the box, but only if you express expectations clearly and in the right format. That is why it is useful to understand conversion not at the level of "somewhere inside there", but at the level of "what formats I promise to the client".
The simplest example: @RequestParam int page. In HTTP everything is a string, so "0" comes in, and Spring has to make 0 out of it. The same applies to @PathVariable long id: "42" comes in and becomes 42. For enums, usually the string "DRAFT" or "IN_REVIEW" comes in, and Spring tries to find the corresponding enumeration value.
But in practice, REST APIs also include "compound" parameters. A classic example from ordinary backends is sort=publishedAt,desc. In Spring MVC + Spring Data this can turn into Sort or become part of Pageable. And here it becomes clear that conversion is no longer "just parse a number", but a fairly important part of the contract: if you change the sort format, clients will break, and tests should tell you that quickly.
To fix the picture, here is a small table of typical conversions that appear in our ContentHub API:
| Where we get the value from | What it looks like in HTTP | What we want to turn it into in the controller method | Java type example |
|---|---|---|---|
| Query param | ?page=0 | page number | int / Integer |
| Query param | ?size=10 | page size | int / Integer |
| Query param (compound) | ?sort=publishedAt,desc | sorting | Sort or part of Pageable |
| Query param | ?status=IN_REVIEW | article status | ArticleStatus (enum) |
| Path variable | /api/editor/articles/42 | article id | long / Long |
| Path variable | /api/public/articles/spring-basics | article slug | String |
And now the important practical conclusion for tests: if binding/conversion is broken, your controller may look "perfect", the services too, but the endpoint will stop being accessible. This is the classic case where "everything works" ends with "except the API".
4. Binding query params
In ContentHub, the public article list endpoint is a great training ground for binding because it almost always accepts query params. Even if today you are building the simplest list, tomorrow the product will ask for pagination, filtering by category, sorting, and, the cherry on top, default values if the client did not send anything.
Suppose our public controller looks roughly like this (at least in terms of meaning; the exact DTOs already exist in your project from previous days):
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
class PublicArticleController {
private final ArticleQueryService articleQueryService;
PublicArticleController(ArticleQueryService articleQueryService) {
// We inject the service through the constructor — this is the standard controller dependency
this.articleQueryService = articleQueryService;
}
@GetMapping("/api/public/articles")
PageResponse<ArticleSummaryResponse> listPublished(
// category is an ordinary query param and may be absent
@RequestParam(required = false) String category,
// We do not "assemble" Pageable explicitly: Spring will try to build it from page/size/sort
Pageable pageable
) {
// The controller does not try to be clever here: it just passes already converted arguments along
return articleQueryService.findPublished(category, pageable);
}
}
There are two immediate "gotchas" for a beginner here: category comes as a string, which is logical, and we did not declare page/size/sort at all, but they still "somehow" end up in Pageable. This is Spring MVC binding + conversion in action: Spring sees that you want Pageable, and through built-in mechanisms tries to assemble it from query params.
What exactly is important to test on this endpoint at the web layer level? We do not need to check that the service really filtered articles by category correctly. We are checking something simpler and more web-layer-ish: that the request parameters are accepted, converted, and passed to the service in the correct form.
5. Binding tests in @WebMvcTest
The most typical beginner mistake is to start checking binding through complex assertions on the JSON response, as if this were an integration test. It hurts: you tie yourself to the response structure, serialization, DTO fields, and in the end the test fails for any reason other than the one you wrote it for.
Much calmer for the brain, and for suite maintenance, is to separate the concerns: if we are testing binding, then the main proof will not be the whole JSON, but the fact that the controller called the service with the right arguments. That is the pure responsibility zone of the web layer: read the input correctly, turn it into parameters, and pass it along.
For such a test, @WebMvcTest, @MockitoBean, and ArgumentCaptor are usually enough. And the request itself is convenient to write through MockMvcTester, because on this day we are precisely "tidying up" the style.
The scheme stays the same as in the previous scenarios: an explicit HTTP request, a short status check, and the main meaning is in verify(...) and ArgumentCaptor, because that is where you can see how the web layer understood the input.
Below is an example test class. Pay attention: the code is intentionally short, without unnecessary details, and it focuses on binding rather than "everything at once".
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerBindingWebMvcTest {
@Autowired
MockMvcTester mvc; // Utility for "clean" requests to the MVC layer without starting a server
@MockitoBean
ArticleQueryService articleQueryService; // Mock service that should receive already converted arguments
@Test
void shouldBindCategoryPageAndSize() {
// The response itself is not important — what matters is that the web layer successfully reaches the service call
given(articleQueryService.findPublished(any(), any()))
.willReturn(PageResponse.empty());
// Send query params in raw HTTP format (strings), then check what they turned into
assertThat(mvc.get()
.uri("/api/public/articles?category=java&page=0&size=10")
.accept(MediaType.APPLICATION_JSON))
.hasStatusOk();
// Capture Pageable to verify the result of binding/conversion
var pageableCaptor = ArgumentCaptor.forClass(Pageable.class);
verify(articleQueryService).findPublished(eq("java"), pageableCaptor.capture());
// Check only the key things: page number and size
assertThat(pageableCaptor.getValue().getPageNumber()).isEqualTo(0);
assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(10);
}
}
A few important nuances are worth spelling out instead of letting the student "figure it out". We do given(...).willReturn(...) not because we care about the response, but so that the controller can successfully finish processing and return 200. Then we verify that the service was called with "java", and that Pageable really contains page=0 and size=10. That is our binding test.
6. Binding sort: publishedAt,desc
Sorting is a classic source of "the request looks correct, but for some reason it is sorted wrong" bugs. And part of the problems is not even in the repository, but in the fact that the client sent sort in a different format, or the controller expects another one.
In an MVC-slice test, we can again avoid comparing the whole JSON and instead verify that Pageable includes the required Sort. In Pageable, sorting lives inside, and we can extract it and look at what exactly happened after conversion.
Here is a compact example focused on sort:
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
@Test
void shouldConvertSortParameterToPageableSort() {
// It is assumed that mvc and articleQueryService are declared the same way as in the previous example
// Stub to make the controller reach the end and return 200
given(articleQueryService.findPublished(any(), any()))
.willReturn(PageResponse.empty());
// Sorting comes in as a string in HTTP, and that is exactly what conversion we want to check
assertThat(mvc.get()
.uri("/api/public/articles?sort=publishedAt,desc")
.accept(MediaType.APPLICATION_JSON))
.hasStatusOk();
// Capture Pageable to see which Sort was produced inside
var pageableCaptor = ArgumentCaptor.forClass(Pageable.class);
verify(articleQueryService).findPublished(any(), pageableCaptor.capture());
Sort sort = pageableCaptor.getValue().getSort();
// Check the sorting direction for a specific field — that is enough for a binding test
assertThat(sort.getOrderFor("publishedAt").getDirection())
.isEqualTo(Sort.Direction.DESC);
}
Here we check exactly what we need: that the string publishedAt,desc became a Sort where the field publishedAt has direction DESC. This is the kind of case where the test really "catches the risk": if you accidentally change the parameter in the controller, or stop accepting sort, or start building Pageable manually and make a mistake, the test will fail.
7. Path variables and conversion errors
Binding path variable id
Query params usually break because of formats and defaults, while path variables break because of more "stupid" reasons: someone changed the path template, someone renamed the variable, someone changed the id type, and as a result the endpoint stopped matching or converting.
You can also test a path variable at the MVC-slice level very simply. Suppose the editor article reading endpoint looks like this: GET /api/editor/articles/{id}, and the controller method takes long id. What matters to us is that the string "42" successfully turns into a number, and then the service receives 42L.
Mini test example, without going deep into security and roles — that is a separate plane, and we are talking about binding now:
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void shouldBindPathVariableIdToLong() {
// Response stub: what matters is that the MVC layer successfully reaches the service call and returns 200
given(editorArticleService.getById(42L))
.willReturn(ArticleDetailsResponse.stub());
// A path variable is always a string, and we want long — that is what we check
assertThat(mvc.get()
.uri("/api/editor/articles/42")
.accept(MediaType.APPLICATION_JSON))
.hasStatusOk();
// Main assertion: the service received the already converted long
verify(editorArticleService).getById(42L);
}
The point here is almost "childish", but useful: if someone accidentally writes @GetMapping("/api/editor/articles/{articleId}") in the controller and forgets @PathVariable("articleId") long id, or changes the type to UUID while the client keeps sending numbers, you will catch the problem at the web test level very quickly.
Invalid parameter type: the service is not called
The question that sooner or later comes up for every student is: "If conversion failed, what happens?" And the correct answer is: most often the controller method simply is not called. That means your @MockitoBean service remains unused, and Spring returns a web-layer error to the client.
Today we are not going into a full error matrix, error payload, and @ControllerAdvice strategy (that is a separate big block), but one important idea about binding is worth fixing right now: an invalid parameter type is not a "business logic error"; it is "the request could not be read".
So sometimes a very short "the service is not called" test is appropriate to fix the boundary. For example, if page must be a number, but page=kitty came in, the request should not accidentally reach the service and start working "somehow". It should fail at the entry point.
Such a test can be written minimally and without discussing the error format:
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void shouldRejectRequestWhenPageIsNotNumber() {
// Important part of the test: the page parameter comes as a "bad" string, and conversion must fail on entry
assertThat(mvc.get()
.uri("/api/public/articles?page=kitty")
.accept(MediaType.APPLICATION_JSON))
.hasStatus4xxClientError();
// If conversion failed, the controller is not called, so the service must not participate at all
verifyNoInteractions(articleQueryService);
}
Here we intentionally do not assert a specific status and do not inspect the error body so as not to mix topics. It is enough for us to fix that the controller will not be naively called with broken parameters, and that the service should not participate in this circus.
8. Typical mistakes in MVC binding tests
Mistake #1: trying to test binding through the full JSON response, as if this were an integration test.
Such a test becomes fragile: it breaks on DTO changes, serialization changes, and any response body tweak. In the end you lose focus: it is unclear whether the test failed because of binding or because of everything else. For binding, it is much more reliable to verify the service call arguments through verify(...) and ArgumentCaptor.
Mistake #2: sweeping HTTP details under the rug for the sake of beauty.
Sometimes students make a helper method that "builds the URL by itself" and hides which query params are actually sent. As a result, the test reads nicely, but it is unclear what exactly we are checking. For binding tests, it is important that the parameter format is visible right in the test: ?sort=publishedAt,desc, ?page=0&size=10, ?status=IN_REVIEW.
Mistake #3: checking too many Pageable and Sort details (overspecification).
If in every test you start checking the entire Sort down to the last comma, the suite starts breaking from the slightest harmless changes. For a binding test, it is usually enough to check one or two "bearing" things: page number, page size, and the presence of a specific order for a key field.
Mistake #4: forgetting defaults and assuming the client will always send all parameters.
In real life, half of the clients send GET /api/public/articles without parameters. If your controller expects @RequestParam int page without a default, it may start failing on an empty request. Even if you do not test default pagination in full, at least one "without parameters" test usually pays off: it confirms that the endpoint is available and does not require the client to know all internal settings.
Mistake #5: mixing binding, filters, error handling, and business logic in one test.
This kind of mashup is especially popular because everyone wants to "check everything with one test". But the result is a test that is hard to read, even harder to maintain, and almost impossible to diagnose quickly when it fails. If the goal of the test is binding, keep it narrow: request -> status -> service call arguments. Everything else should live in tests of its own layer and purpose.
GO TO FULL VERSION