1. Starting with public endpoints
If you have ever tried to “get into controller testing” right away with an admin API, multipart upload, and tricky authorization, you know the feeling: there seem to be lots of tests, but confidence is zero, and it is still unclear what exactly you are testing. That is why starting with public endpoints is not “simplification for wimps,” but a normal engineering strategy: first we stabilize the simplest external contract.
The JSON shape of the DTO is already checked faster and more precisely on its own. Here the task is different: to see how the route, path variable, status, and JSON response are assembled around the controller. So for the first web-slice tests, a basic rhythm of GET -> status -> a couple of jsonPath checks is enough, without trying to unravel the whole HTTP machinery right away.
Public endpoints are good because they are usually read-only, they have a minimal amount of side effects, and that makes the tests more transparent. We are not arguing about transactions, we are not bringing up the database, and we are not thinking about ownership rules (whose draft belongs to whom). We are simply proving that the URL route is correct, the controller calls the right dependency, and JSON goes out in the expected shape.
To keep the boundary in mind, it helps to picture the MVC slice like this (roughly, but very close to the truth):
flowchart TD T["Test method"] -->|"mockMvc.perform(...)"| D["DispatcherServlet"] D --> C["PublicArticleController"] C --> S["PublicArticleService (mock)"] S --> C C --> D D --> T
The key word here is mock. We are not “fooling the system”; we are honestly saying: “Business logic and data are not the focus of this layer, so we will inject their result by hand and see how the web boundary returns it.”
2. The boundary: controller, DTO, and service mock
Before writing a test, it is useful to spend literally a minute and “ground” in your head what exactly you are testing. In our ContentHub project, the public part of the API looks like this: a list of published articles and an article card by slug. In the real application, the controller may be richer, but for the first tests it is important to see two entry points.
Here is a simplified (but recognizable) controller fragment. Not so that we write production code right now, but so that the tests below read like “we are checking this exact contract”:
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/public/articles") // Base path of the public article API
class PublicArticleController {
private final PublicArticleService publicArticleService;
PublicArticleController(PublicArticleService publicArticleService) {
// Constructor injection: the dependency is passed to the controller explicitly
this.publicArticleService = publicArticleService;
}
@GetMapping // GET /api/public/articles
PageResponse<ArticleSummaryResponse> listPublished() {
// For the collection endpoint, the controller simply returns a page-wrapper DTO outward
return publicArticleService.listPublishedArticles();
}
@GetMapping("/{slug}") // GET /api/public/articles/{slug}
ArticleDetailsResponse getBySlug(@PathVariable String slug) {
// In the web-slice we care about whether the service is called correctly and a DTO is returned
return publicArticleService.getPublishedArticleBySlug(slug);
}
}
Notice two things. First, the controller holds on to the service via constructor injection (as the project requires). Second, the public API communicates through DTOs, not JPA entities — and that makes life much easier: in a web test we mock the service and return DTOs as the “ready answer.”
Since we already had a separate layer for JSON contracts, you have already seen DTOs in @JsonTest. Here we should not repeat the same checks “100%”, but we must at least make sure that the controller really returns what it promises the client: for example, slug and title should at least be there, and not accidentally renamed or hidden.
Now both basic public contracts are visible on the controller — the list and the details. So the first list test no longer hangs in the air.
3. The scaffold: @WebMvcTest and MockMvc
Now we will assemble the test class so that it is as close to the canon as possible: one controller — one test class, controller dependencies are replaced through @MockitoBean, and we will work with MVC through MockMvc. At this moment many beginners expect magic, but the magic here is very everyday: Spring starts the web part, and you send a request through MockMvc “as if it were HTTP,” but without starting a server.
Let us start with the skeleton. It is useful by itself: you can create and run such a class — and make sure the context comes up.
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;
@WebMvcTest(PublicArticleController.class) // We bring up only the web slice around one controller
class PublicArticleControllerWebMvcTest {
@Autowired
MockMvc mockMvc; // We use MockMvc to execute requests without starting a real server
@MockitoBean
PublicArticleService publicArticleService; // We replace the service with a mock: business logic is not the focus of the web slice
}
The logic here matters: PublicArticleService must not be a real Spring bean in this test. We do not need the database, repositories, or transactions. We just need: “if the service says this, the controller will return that.”
For now, all we need is the basic path through MockMvc: GET, status(), and a few jsonPath assertions. That is enough to see the slice working around the controller without drowning in headers, media type, and request body right away.
Sometimes in a public API you also need to see a single error contract (for example, ApiProblem), which is usually formed through @ControllerAdvice. If a unified error contract matters in the scenario, it is better to make ApiExceptionHandler a visible part of the setup — that is why it is often convenient here to include such advice explicitly via @Import. If a particular slice already picks up the advice on its own, you do not need to import it again.
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
@WebMvcTest(PublicArticleController.class)
@Import(ApiExceptionHandler.class) // Explicitly include the advice because these tests care about the error contract
class PublicArticleControllerWebMvcTest {
// ...
}
We are not turning the test into a full context, we are just adding one important web component that really affects what the client sees.
4. Happy path: the article list
It is better to start with the most “direct” endpoint: GET /api/public/articles. Even if the real project has pagination, sorting, and filters there, the first test should be as calm as possible: no parameters, no complicated logic, just the basic response shape. Our goal is to feel the rhythm of the MVC test: stub → perform → assert.
First we sketch what the service returns. Suppose the public list returns not a “bare list” but a PageResponse<T> wrapper (in the project it is needed so we do not expose raw Page<T> outside). In the test, it does not matter: we return the object and check that it was serialized correctly.
The test will look something like this:
import org.junit.jupiter.api.Test;
import java.util.List;
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.*;
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
PublicArticleService publicArticleService;
@Test
void returnsPublishedArticlesList() throws Exception {
// Arrange: prepare the DTO that our service mock will "return"
var page = new PageResponse<>(
List.of(new ArticleSummaryResponse("spring-slice", "Spring Slice")),
0, 20, 1
);
// Stub: mock the service behavior (in the web slice the service is not real)
given(publicArticleService.listPublishedArticles())
.willReturn(page);
// Act + Assert: perform the HTTP request and check the key response fields
mockMvc.perform(get("/api/public/articles"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].slug").value("spring-slice"))
.andExpect(jsonPath("$.items[0].title").value("Spring Slice"));
}
}
Yes, this test looks like “I manually put the DTO there, and then checked that the JSON came back correctly.” And that is fine: in @WebMvcTest we are precisely checking the web boundary. We are not proving here that the article is really published (that is business logic), and we are not proving that the database query is correct (that is the data layer). We are proving: the endpoint exists, the controller calls its dependency, the response is serialized, and it contains the key fields.
Notice an important bit of discipline. We are not checking the entire JSON, not doing ten jsonPath assertions in a row, and not comparing huge nested structures. For the first test, a couple of load-bearing fields is enough: slug and title. If they are there, the chance that “everything else somehow disappeared” is fairly small, and the test remains readable.
5. Happy path: article by slug
The second basic public contract is the detail card of an article by slug: GET /api/public/articles/{slug}. The idea of the test is the same: we do not check how the service finds the article, and we do not check visibility business rules. We simply fix the web contract: the path variable is bound correctly, the controller calls the service with the right slug, and the client receives JSON with the expected fields.
Let us make the test a little more “alive” and add a couple of fields so that it feels like a details endpoint, not a repeated summary.
import org.junit.jupiter.api.Test;
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.*;
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
@Autowired MockMvc mockMvc;
@MockitoBean PublicArticleService publicArticleService;
@Test
void returnsArticleDetailsBySlug() throws Exception {
// Stub: the service must receive exactly the slug that came in the path variable
given(publicArticleService.getPublishedArticleBySlug("spring-slice"))
.willReturn(new ArticleDetailsResponse(
"spring-slice",
"Spring Slice",
"Short summary",
"Article body"
));
// Check: the route exists, the slug is bound, and the DTO is serialized into the expected fields
mockMvc.perform(get("/api/public/articles/spring-slice"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.slug").value("spring-slice"))
.andExpect(jsonPath("$.title").value("Spring Slice"));
}
}
Here you may notice an interesting psychological moment. While writing the test, your brain wants to say, “and let’s also check summary, body, category, publishedAt...” But exactly in the web slice, at the start, it is important to keep yourself in check. Overly detailed checks turn a controller test into a “weak copy of a JSON-contract test,” and that is a bad deal: the test becomes brittle, and the value does not grow as much as the number of lines.
And another useful thought: even if your DTOs are already tested with @JsonTest, the controller test is still useful. It catches routing errors (@RequestMapping), path variable binding, incorrect statuses, forgotten @ControllerAdvice, unexpected filters/interceptors, and everything else that exists only in the web layer.
6. Negative case: 404 and ApiProblem
Negative scenarios are a separate big topic, but testing a public API without them is strange: the client needs to understand what happens when the article is missing. We will not build a full matrix of statuses here or argue about every edge case. One clear scenario is enough: the article is missing — the client gets a correct 404 with the expected error payload.
Suppose the service throws ArticleNotFoundException, and ApiExceptionHandler (our @ControllerAdvice) turns it into ApiProblem with errorCode = "ARTICLE_NOT_FOUND" and status 404.
The test will look like this:
import org.junit.jupiter.api.Test;
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.*;
@WebMvcTest(PublicArticleController.class)
@Import(ApiExceptionHandler.class) // Explicitly include the advice because here we verify the error contract
class PublicArticleControllerWebMvcTest {
@Autowired MockMvc mockMvc;
@MockitoBean PublicArticleService publicArticleService;
@Test
void returns404WhenArticleNotFound() throws Exception {
// Stub: the service reports "not found" through an exception (the controller does not catch it itself)
given(publicArticleService.getPublishedArticleBySlug("missing"))
.willThrow(new ArticleNotFoundException("missing"));
// Check that @ControllerAdvice really formed the right HTTP status and payload
mockMvc.perform(get("/api/public/articles/missing"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.errorCode").value("ARTICLE_NOT_FOUND"));
}
}
The point of this test is not to “check the whole error contract.” The point is to fix that the client will not get unexpected HTML with a whitelabel error page, will not get 500 instead of a clear 404, and that the error payload at least minimally matches the expected format.
If this test fails because ApiExceptionHandler did not make it into the slice, that is also useful diagnosis. You immediately see that the controller itself is alive, but the web error-handling infrastructure is not connected in the slice, which means the test context does not reflect real web behavior.
7. Assertions: strong, but not brittle
In controller tests there are two opposite extremes, and both look tempting until you live with them for a week. The first extreme is “I will only check status 200, and that is it.” That proves almost nothing: you can return an empty object, you can return a completely different JSON, you can forget the slug field, and the test will remain green.
The second extreme is “I will check the entire JSON down to the last comma.” That turns a controller test into a snowflake: any cosmetic DTO refactoring, any added field, even a change in property order during serialization (yes, that happens too) — and the tests start screaming, even though the contract is not broken in meaning.
That is why in the first public tests we deliberately keep HTTP checks short. Status plus a few load-bearing JSON fields already provide value; headers, Content-Type, request body, and more detailed body assertions make sense when the layer boundary itself is no longer confused with business logic.
A healthy middle is usually this: for each endpoint you choose several key checks that really carry meaning. For the article list, this is usually items and one element inside it (for example, slug, title). For details, this is slug and title plus, possibly, one “unique” field of the details response (for example, body), but only if it is really critical for the client.
It is very useful to keep a simple rule in mind: if your check is a “duplication of what @JsonTest already checked,” then either make it shorter, or stop doing it. And if your check is “something that can break at the web boundary even with perfect DTOs,” then that is exactly a good candidate for @WebMvcTest.
To reinforce this idea, you can compare the layers in a small table:
| What broke | Where it is better to catch it | Why |
|---|---|---|
| JSON field renamed in DTO | @JsonTest | Fastest feedback, without MVC |
| @RequestMapping or path broke | @WebMvcTest | Visible only at the web boundary |
| Service filters published items incorrectly | unit/integration, but not @WebMvcTest | That is not the controller's responsibility |
| Repository sorts incorrectly | @DataJpaTest | That is already data-layer behavior |
In this lecture we deliberately stay on the top line of the “web boundary,” and that is normal.
Mini-refactoring: DTO factories in the test
As soon as you write 2–3 tests, you get tempted: “now I will make a universal builder, a factory of factories, and a DSL so that everything looks like a poem.” This is a dangerous spot. Too clever abstractions in tests often hide the meaning. But completely avoiding repeated code is also hard: writing long DTO constructors again and again in each test is unpleasant, and sometimes it also distracts from the point.
In the first controller tests, small helper methods right inside the test class are usually enough. They should be dumb and obvious — like a good toaster: press it, and the bread gets warm, without any “cloud integration.”
For example:
private static ArticleSummaryResponse summary(String slug, String title) {
// Minimal helper: makes the test shorter, but does not hide the meaning
return new ArticleSummaryResponse(slug, title);
}
private static ArticleDetailsResponse details(String slug) {
// Dumb factory with defaults: convenient while there are few fields and they are not critical to the scenario
return new ArticleDetailsResponse(slug, "Title", "Summary", "Body");
}
Now the tests become visually shorter, but the meaning does not get lost:
given(publicArticleService.getPublishedArticleBySlug("spring-slice"))
// The service returns a prebuilt details DTO
.willReturn(details("spring-slice"));
An important caveat: if you catch yourself making helper methods accept 10 parameters and build a “magic object” whose contents are unclear, you have already crossed the line. For the first web-slice tests, simplicity is better than “test architecture at the level of a startup at peak investment.”
8. Typical mistakes in the first @WebMvcTest for public API
First controller tests usually break not because Spring is “complicated,” but because we accidentally go beyond the slice boundary or, conversely, make the checks too weak. That is normal: the brain is still switching from “verify a feature” to “verify a layer.” Below are a few typical traps I have seen with students (and, honestly, a couple of them I caught myself when I was young and naive).
Mistake #1: trying to “test the service through the controller.”
The most common scenario: you write @WebMvcTest, then realize the service did not start, and begin dragging half the application into the context — repositories, configs, datasource. As a result, the test becomes slow, complicated, and stops being local. In the web slice, the service should be mocked, and business rules should live in unit and integration tests.
Mistake #2: overly detailed JSON assertions.
It is very tempting to pin down the whole JSON exactly, especially when you have just learned jsonPath. But such a test starts failing on any cosmetic DTO change: add a field — test fails, change the date format — test fails, reorder fields — test fails. At the start, it is better to check 1–3 key fields that really carry the contract.
Mistake #3: missing @ControllerAdvice in the slice and sudden “wrong” errors.
You expect 404 with ApiProblem, but you get 500 or a strange default error body. The reason is often simple: the exception handler did not make it into the slice. The fix is not to “pull in the whole context,” but to connect the needed @ControllerAdvice surgically through @Import, so the web behavior becomes production-like.
Mistake #4: mixing multiple controllers in one test class.
This looks convenient right up to the first failure. Then you start figuring out: “why did the public endpoint test break the admin service mock?” — and realize you created a tangle of dependencies yourself. One controller — one test class makes the suite more boring, but more predictable. And boring tests are a compliment, if anything.
Mistake #5: too “noisy” stub data.
When you build a DTO with twenty fields just to check slug and title, extra noise starts in your head: the test reads like “the story of data assembly,” not like “a check of API behavior.” In such tests, minimal but meaningful responses are better. If the data matters — check it. If it does not matter — do not drag it into the scenario.
GO TO FULL VERSION