CodeGym /Courses /Spring Test /Controller test boundary

Controller test boundary

Spring Test
Level 13, Lesson 4
Available

1. Controller test boundary: saving your nerves

If you look at tests as “magic green check marks,” boundaries are unnecessary: let us just verify everything everywhere, and then wonder why the suite runs for 6 minutes and fails because of an extra space in JSON. But if you look at tests as a risk-management tool, then the boundary is literally the “zone of responsibility,” like a border guard: they check the passport (HTTP input), stamp it (HTTP output), and are not obliged to know how metallurgy works in the destination country.

A controller test is the fastest way to pin down the external API contract within Spring MVC: how we accept a request, how we validate input, how we turn exceptions into a stable ApiProblem, which headers and status codes we return, and what the response body looks like. It is useful precisely because it does not drag in a database, a file system, or business orchestration. The moment you start “proving” sorting, SQL queries, and writing a file to disk in an MVC test, you turn a fast test into a slow and fragile one — and still do not get a truthful data/storage-level proof, because those things are no longer in its context.

The key phrase of today’s lecture sounds boring, but it will save you dozens of hours: a controller test starts with HTTP input and ends with HTTP output. Everything else is only when needed, in the smallest possible amount, and without the desire to “catch every bug in the world with one test.”

2. MVC slice as an HTTP boundary lab

When you write @WebMvcTest, you are not testing “the application.” You are testing “how our application looks from the outside at the HTTP level” — and that is a fundamentally different object. If you keep that picture in your head, it is easier not to drift into two extremes: either a test for just 200 OK, or a test for the entire universe, including the birth of stars and writing a file to disk.

Let us draw that boundary so that it is easy to remember later what exactly you are checking.

flowchart TD
    A[HTTP Request] --> B["Spring MVC: mapping/binding/validation"]
    B --> C[Controller method]
    C --> D["Service mocked"]
    D --> C
    C --> E["Response mapping: status/headers/body"]
    E --> F[HTTP Response]

    subgraph "MVC slice under test"
      B
      C
      E
    end

    subgraph "Outside slice usually mocked"
      D
    end

Inside the MVC slice, you have things that really belong to the web layer: @RequestMapping, @PathVariable, @RequestParam, @RequestBody, type conversion (including page/size/sort), validation annotations on DTOs, error handling via @ControllerAdvice, response serialization to JSON, and response headers. And all of that is a fair target for a controller test.

Outside the slice usually live your services, which do the “real work”: pick articles, apply business status rules, talk to the database, communicate with file storage, or with an external moderation client. In a controller test, these dependencies are most often replaced via @MockitoBean, because otherwise the test stops being a controller test and turns into a very strange mixture of everything at once.

A good practical rule: if, when a controller test fails, you open repository code or start reading SQL, then you have already lost the boundary.

3. What to check in a controller test

A controller test is a contract check. A contract is not just a URL. A contract is everything the client can observe: which parameters we accept, which errors we consider a “client error,” which we consider “resource not found,” which we consider a “state conflict,” and what all of that looks like in the response.

To avoid turning this into a list of “25 items,” let us format it as a table: what we check here, on the MVC layer, because it is either a pure HTTP contract or Spring MVC binding/validation that shows up only on the web boundary.

Part of the behaviour What exactly we pin down with the test Why this is a web-layer concern
Routing URI + HTTP method reach the right handler This is the controller’s responsibility and @RequestMapping
Binding page/size/sort/category are converted into the correct types and values This is done by Spring MVC conversion/binding, and it is part of the contract
Defaults A missing parameter gets a default value The client must know what happens without parameters
Validation Invalid input → 400 and ApiProblem with violations This is input “border control”
Format errors Malformed JSON/type mismatch → correct error payload The client must receive a stable error format
Status codes 200/201/400/404/409 in the right places This is the language of the API, and it cannot be left to chance
Headers Content-Type, Location, Content-Disposition Headers are part of the contract, especially for files
Body JSON fields in the response, minimum critical values The client reads the body, so we must pin it down
Error contract ApiProblem.status, title, errorCode, etc. If the error contract drifts, clients break

And now an important nuance. When we say “check the body,” it does not mean “compare the whole JSON character by character.” In real life, JSON often evolves slightly: fields are added, insignificant serialization details change, and field order changes (which JSON does not promise anyway). In a controller test, we usually care about semantic anchor points: status, items, page, size, errorCode, and the key fields of the response item. Everything else is situational.

A mini-example of the “right focus” for a public list: we do not check the entire wrapper down to the last detail, but we pin down exactly those fields that make the API useful to the client.

import static org.mockito.ArgumentMatchers.any;
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.*;

// Arrange: prepare the service layer (outside the MVC slice) as a "black box".
given(articleService.findPublished(any()))
        .willReturn(pageResponse);

// Act + Assert: check only the HTTP contract (route, status, key JSON fields).
mockMvc.perform(get("/api/public/articles")
        .param("page", "0")
        .param("size", "5"))
        .andExpect(status().isOk())
        // Contract: the page wrapper must preserve the expected response shape.
        .andExpect(jsonPath("$.page").value(0));

Here the test proves that the route works, the controller accepts the public list request, the service result is turned into the expected JSON response, and the status is correct. It does not prove that the articles were actually selected and sorted correctly inside the service — and that is fine, because that is already a different risk zone.

4. What a controller test does not prove

It is very easy to confuse “input/output contract” with “internal logic.” Especially when you have just learned Mockito, and your hands are itching to verify every call, every argument, and the order. That is normal: the brain is happy that it can now “control reality.” But the testing reality does not become more useful because of that — it becomes more fragile.

A controller test does not have to prove that sorting really sorts the way it is intended inside the service. At the MVC layer, we prove something else: that the sort=publishedAt,desc parameter is accepted at all, validated, mapped correctly, and passed along. The sorting itself is the responsibility of the layer that actually selects the data. Otherwise you end up duplicating work: you test sorting in service tests and then test sorting again in controller tests, but the second time on mocks, that is, in practice, on your own assumptions.

A controller test does not have to prove file system behaviour. For upload/download, we pin down the HTTP shape: what the multipart request looks like, how the error behaves, which headers the download has, and which bytes we return if the controller dependency has already given us a payload. If, in the MVC slice, you start writing temporary files in tmp and checking that they appear on disk, then that is already a file adapter test, not a controller test.

A controller test does not have to check JPA mappings, unique constraints, and the SQL dialect. Even if you really want to. Even if “everything is nearby.” Even if the database caused a failure last time. A controller test has a different cost and a different goal: fast feedback on the contract. Try to mix it all together — and you get a slow test that still does not provide honest confidence.

There is a useful indicator: if the test starts looking as if you are writing a “stage play script” (the controller called the service, the service called the repository, the repository called… and then everyone bowed), then you are checking not behaviour, but the internal production. That is called overspecification, and it almost always leads to unnecessary test failures during harmless refactoring.

5. Mockito on the web boundary: when to stop

Mockito in a controller test is needed not “for beauty” and not “because that is the custom,” but so that you can isolate the HTTP contract from everything else. But here is the paradox: the most useful Mockito here is often the smallest one. One given(...) and one verify(...) can give more confidence than twenty interaction checks.

Usually a controller test has two main Mockito scenarios. The first is that the service returns a result, and we check how the controller turns it into an HTTP response. The second is that the service throws a domain exception, and we check how @ControllerAdvice turns it into an ApiProblem.

The example with 404 on the download path shows that you are testing the web boundary itself: the error did not “leak out as a stack trace,” but became a normal JSON response.

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.*;

// Arrange: the controller dependency reports that the attachment was not found.
given(attachmentService.download(10L, 99L))
        .willThrow(new AttachmentNotFoundException(99L));

// Act + Assert: at the web boundary, the exception must become a stable HTTP response.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 99L))
        .andExpect(status().isNotFound())
        // Error contract: the client expects a specific errorCode.
        .andExpect(jsonPath("$.errorCode").value("ATTACHMENT_NOT_FOUND"));

Now about ArgumentCaptor. It is a powerful thing, but dangerous, like a chainsaw in the hands of someone who only wanted to cut bread. A captor is useful when you want to prove: “the controller correctly assembled request parameters into a service request object.” Not “the service correctly filtered,” but exactly “the controller correctly assembled.”

Here is an example with pagination and sorting: we do not prove sorting, we prove that page/size/sort/category went into the service request as expected.

import org.mockito.ArgumentCaptor;
import static org.mockito.BDDMockito.then;

// Captor is needed to verify how the controller assembled parameters into the request object.
ArgumentCaptor<PublicArticleQuery> captor =
        ArgumentCaptor.forClass(PublicArticleQuery.class);

// Act: send an HTTP request with query params (this is the MVC responsibility zone).
mockMvc.perform(get("/api/public/articles")
        .param("page", "2")
        .param("size", "5")
        .param("sort", "publishedAt,desc")
        .param("category", "java"))
    .andExpect(status().isOk());

// Assert: verify that the controller delegated the call and passed the assembled query object.
then(articleService).should().findPublished(captor.capture());

Important point: after that, you usually make 1–3 meaningful asserts on the captured object, and stop there. If you start checking every little thing (down to “what spaces are in the sort string” or “is it exactly ArrayList and not List”), the test turns into a guard for internal details. And we need to guard the contract.

6. Breakdown: list, upload, and download

Now we will go through three of the most “slippery” zones of the day: public list (query params), upload (multipart), and download (file response). The point is not to repeat previous lectures, but to see one common line: what stays in a controller test, and what we consciously do not bring in there.

Let us start with the public list. Here the controller owns several things: accepting page/size/sort/category, returning the page wrapper (PageResponse), and returning 400 if the parameters are invalid. At the same time, the controller does not have to understand how exactly the articles were filtered internally. Therefore, a good test usually does this: the service returns “some page” (fixed), and we check the shape.

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

// Arrange: the service returns a fixed PageResponse; the selection details do not matter here.
given(articleService.findPublished(any()))
        .willReturn(pageResponse);

// Act + Assert: check the page contract in JSON (page/size, etc.).
mockMvc.perform(get("/api/public/articles")
        .param("page", "0")
        .param("size", "5"))
    .andExpect(status().isOk())
    // Contract: the controller returns "page" and "size" in the expected form.
    .andExpect(jsonPath("$.page").value(0))
    .andExpect(jsonPath("$.size").value(5));

Now upload. At the web boundary we check: the multipart field name, status, the error contract for an empty/too large file, and (if the endpoint returns metadata) the key metadata fields. The actual file write to storage is not part of this.

import org.springframework.mock.web.MockMultipartFile;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;

// Important: the field name ("file") is part of the HTTP multipart request contract.
MockMultipartFile file = new MockMultipartFile(
        "file", "cover.png", "image/png", new byte[] {1, 2, 3}
);

// Act + Assert: check status and key response fields, not the real file save.
mockMvc.perform(multipart("/api/editor/articles/{id}/attachments", 10L)
        .file(file))
    .andExpect(status().isOk())
    // Contract: the response returns the original file name.
    .andExpect(jsonPath("$.originalFilename").value("cover.png"));

If you want to add a minimal verify(...) to emphasise the boundary, it should sound like “the controller delegated attachment processing,” not like “the controller called the service, then the service called three more methods.” One call is fine, especially if you have caught the bug “the dependency was not called at all” or “the wrong one was called.”

And finally download. Here the main attraction is headers. An attachment without Content-Disposition and without a correct Content-Type often looks to the client like “something strange opened in the browser” or “it downloaded without a name.” And that is perfect for a controller test, because headers are formed by the web layer.

// Act + Assert: the key download check is the response headers (this is part of the contract).
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
    .andExpect(status().isOk())
    .andExpect(header().string(
            "Content-Disposition",
            // Contract: the browser/client must receive a correct file name.
            "attachment; filename=\"cover.png\""
    ));

And checking the byte body is a separate “hot sauce.” It is appropriate if the response is small and fully deterministic. In the educational example with new byte[] {1, 2, 3} — absolutely. But in real life, if the body is large or streamed, you usually pin down more stable things: length, type, the fact that there is content. Otherwise tests start failing because of any tiny thing that the user does not even notice.

// This assert is appropriate when the body is small and fully deterministic.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
    .andExpect(content().bytes(new byte[] {1, 2, 3}));

The important line: we do not check “how the service got the bytes.” We check that the HTTP response looks correct once the bytes are already there.

7. Decision map for the test level

Sometimes the most useful outcome of a lecture is not a new API, but the ability to stop and choose the right type of test. For the controller layer, it is useful to keep a couple of questions in mind that quickly bring you back to the boundary. Imagine that you want to write a test, and ask yourself: “Am I checking the form of the HTTP interaction or the internal truth of the system?”

Here is a compact navigator table for typical “I want to check...” cases:

“I want to check…” If this is about the HTTP contract If this is about internal logic
Parameters page/size/sort Controller test: binding, defaults, 400 for bad values Service/data: what is really sorted and how
Filter category=java Controller test: accepts the parameter, validates format Service/data: selection, join logic, order
“Resource not found” error Controller test: 404 + ApiProblem.errorCode Data/service: why it was not found and under which conditions
Empty file upload Controller test: 400 + stable error contract Service: business constraints, limits, policy
Attachment download Controller test: Content-Disposition, Content-Type, body Storage: reading/streaming/file nuances

And one more rule “by ear.” If your check sounds like “it must be 404 and this JSON,” it is almost always a controller test. If it sounds like “the correct article must be selected from the database under a complex condition,” it is almost always another layer. The controller is a translator and border guard, not a librarian searching for a book in an archive.

8. Typical mistakes in controller tests

Mistake #1: the MVC test proves business truth.
In controller tests, what usually breaks is not Spring, but our own enthusiasm. The most common mistake is to write an MVC test as if it had to prove business truth. As a result, you end up with a huge test with a lot of mocks that checks not the contract, but the scenario of internal calls. Any service refactoring (even without changing external behaviour) starts breaking tests, and the suite turns into a punishment for good engineering habits.

Mistake #2: the test turns into a “check 200 OK.”
The second common mistake is turning a controller test into a “check 200 OK.” It seems like there is a test, there is a check mark, and then it turns out that the client gets the wrong headers, the default page suddenly became 1 instead of 0, sort broke because the format changed, and ApiProblem drifted, so the frontend cannot show the error properly. The contract consists of details, and it is the details that make the API usable.

Mistake #3: too much love for verify(...).
The third mistake is an excessive love for verify(...). One verify sometimes helps emphasise the boundary and catch the bug “delegation was not done.” But ten verifys almost always mean the test has started describing internal orchestration. Checking the order of calls and exact arguments where that is not part of the contract is especially toxic. It turns the test into a fragile scenario instead of a guard for behaviour.

Mistake #4: real resources are mixed in unnecessarily.
The fourth mistake is mixing in real resources where they are not needed. A typical example: a download test that really creates a file on disk and then downloads it. Yes, technically it “works,” but the price is coupling, slower execution, CI problems, and a lot of pain under parallel runs. In the MVC slice, it is enough for the controller dependency to return bytes and metadata, and for you to check the HTTP response.

Mistake #5: negative scenarios are not pinned down as part of the contract.
The fifth mistake is not pinning down negative scenarios as part of the contract. Upload without checking an empty file, pagination without checking size=0, download without a 404 branch — that is “there is a test, but no insurance.” In a real API, negative scenarios are often what break client integrations, because the happy path is usually checked by hand, by eye, and by every other means available.

1
Task
Spring Test, level 13, lesson 4
Locked
Article Card Without Crossing the HTTP Layer
Article Card Without Crossing the HTTP Layer
1
Task
Spring Test, level 13, lesson 4
Locked
Building a query object at the controller boundary
Building a query object at the controller boundary
1
Survey/quiz
Public contract, level 13, lesson 4
Unavailable
Public contract
Boundary tests for the HTTP API
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION