1. How negative tests turn into chaos
Negative tests almost always appear in real life: yesterday you had a happy path, today a bug report landed, tomorrow production went down because of an invalid request body, and the day after that someone changed the error payload and the client app started to complain. At that point, your hand naturally reaches for “just one more test” — and that is correct. The problem is different: if you write them haphazardly, after 20–30 cases you end up with a set of methods like test1, test2, shouldReturn400WhenX, shouldReturn400WhenY, which nobody can read as a system.
The most typical picture of chaos looks like this: one test checks @NotBlank, @Size, that service was not called, and that ApiProblem.title is exactly such and such (which will be renamed tomorrow). A second test does almost the same thing, but for a different endpoint. A third one uses a different status and one more condition. The tests are green, but there is no confidence, because the suite starts to look like a box of tangled cables from old chargers: there is “something”, but it is scary to plug in.
To pull yourself out of this state, it is useful to move from a “list of cases” to a matrix: we have a finite number of editor API endpoints and a finite number of error classes. And if we honestly name these axes, then each test automatically gets a clear place in the structure.
2. Matrix: endpoint × error class
When we say “matrix,” do not be afraid: this is not linear algebra and not “vectors in Spring space.” It is literally the school idea of a “multiplication table,” only instead of numbers we have our endpoints and types of negative scenarios. The matrix helps in two ways: first, it shows what we have already covered; second, it helps avoid duplicates and not forget important error classes. And yes, it also eases anxiety: instead of the feeling “I am not covering anything,” you get the feeling “here is the system, I am closing risk after risk.”
In the ContentHub editor API, it is convenient to take a minimal end-to-end set of endpoints that often break on input and on status transitions:
- POST /api/editor/articles (create a draft),
- PUT /api/editor/articles/{id} (update a draft),
- POST /api/editor/articles/{id}/submit (submit for review).
For error classes, we will take the ones we already discussed today:
- 400 due to validation failure,
- 400 due to malformed JSON,
- 400 due to type mismatch,
- 404 resource not found,
- 409 conflict (business state conflict),
- 500 unexpected (fallback).
We also discussed 415 Unsupported Media Type, but we keep it a bit aside from the main matrix. It is a useful transport-level guardrail for headers and Content-Type: usually 1–2 separate tests are enough, and there is no need to make it the central axis of the editor flow alongside validation, not found, and conflict.
In outline, this can be represented like this (this is not a “contract,” but a convenient map):
| Endpoint | 400 (validation) | 400 (malformed) | 400 (type mismatch) | 404 | 409 | 500 |
|---|---|---|---|---|---|---|
| POST /api/editor/articles | yes | yes | rarely | rarely | sometimes | yes |
| PUT /api/editor/articles/{id} | yes | yes | yes | yes | sometimes | yes |
| POST /api/editor/articles/{id}/submit | sometimes | no | yes | yes | yes | yes |
It is important to understand that “yes/no” here is not a mathematical truth. It is a hint: where to expect the corresponding class of errors so the suite looks not like a list of random events, but like coverage of specific risks. For example, on submit we usually do not have @RequestBody, so malformed JSON is not as relevant there, but 404 (article not found) and 409 (invalid status transition) are very relevant.
3. The rule “one test — one source of failure”
In negative tests there is a very simple but life-saving discipline: change exactly one factor compared to the “canonically valid request.” This sounds banal, but this is precisely the moment when “study advice” suddenly turns into “professional hygiene.” When you change one factor, you know exactly why the test failed. When you change three, the test becomes a mystery, and you start debugging the test instead of the application.
The best helper here is a “canonical valid JSON.” It should be as boring as possible. Do not put special characters, emojis, or poetry in it (we are testing the backend, not creative writing). Boring JSON is a superpower, because in each negative test you replace only one detail.
Mini example of such a helper:
private String validCreateArticleJson() {
// Canonical valid JSON: as boring as possible, no surprises.
// In negative tests we will change exactly one detail relative to this body.
return """
{"title":"Title","summary":"Summary","body":"Body","category":"JAVA"}
""";
}
After that, any negative test becomes a “targeted mutation”:
- if you need to check @NotBlank on title — change "Title" to "" or " ";
- if you need to check @Size(max=120) — make the string 121 characters long;
- if you need to check that a field is missing — remove "category".
And here is an important psychological bonus: when you read the test, you do not need to understand the whole request again. You look at the diff: “aha, only title is empty here — so this test is about title being required.” This greatly reduces cognitive load, especially if you are not yet confident with Spring magic and sometimes forget where @RequestBody is and where @PathVariable is.
4. Test structure: one controller, but many @Nested blocks
In the examples below, we use JUnit 6. (The annotations @Nested, @DisplayName, and @ParameterizedTest keep the same meaning, because this is still JUnit Jupiter.)
When negative scenarios become more than five, a “flat” test class starts to turn into a sheet of endless text. And here @Nested is not about “beauty for beauty’s sake,” but about making the suite readable like a document: first we see the endpoint, then inside it — the classes of errors. If you know the feeling of “I opened a test class and scrolled 200 lines down, and everything is the same” — @Nested is exactly what heals that pain.
A skeleton structure for the editor API might look like this:
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@WebMvcTest(EditorArticleController.class)
class EditorArticleControllerWebMvcTest {
// Group by endpoints: this makes the class read like an API table of contents.
@Nested
@DisplayName("POST /api/editor/articles")
class CreateDraftErrors { }
@Nested
@DisplayName("PUT /api/editor/articles/{id}")
class UpdateDraftErrors { }
@Nested
@DisplayName("POST /api/editor/articles/{id}/submit")
class SubmitForReviewErrors { }
}
Notice an important detail: we group tests by API shape, not by internal service methods. This keeps tests closer to the API consumer and to the documentation. Plus, if tomorrow someone moves an endpoint or changes the URI, you immediately know which tests are affected.
Then inside each @Nested class you can already “put the shelves”: separate tests for 400, for 404, for 409. But do not go overboard. We are not building a test cataloging library — we are building a clear suite.
5. Helper methods: less noise, but HTTP must stay visible
The desire to write a “super-helper” for everything at once is completely natural. Especially when you are writing .contentType(APPLICATION_JSON) .accept(APPLICATION_JSON) for the third time and feel a small framework being born inside you (and then you will have to maintain it yourself, congratulations). So the rule is this: helpers should remove repetitive noise, but not hide the meaning of the request.
One of the safest helpers is a small “POST JSON” or “PUT JSON” wrapper that still keeps the URL and the body visible:
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
private ResultActions postJson(MockMvc mvc, String url, String json) throws Exception {
// A small helper removes routine work (Content-Type/Accept),
// but keeps the key things visible: the URL and the request body.
return mvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON) // say: "I am sending JSON"
.accept(MediaType.APPLICATION_JSON) // say: "I want JSON back"
.content(json)); // the request body itself (do not hide it!)
}
Why is this a good compromise? Because when you read the test, you still see “where” (url) and “what” (json). But the extra details of “how exactly Content-Type is configured” are hidden, because that is truly a repetitive ritual. In the end, the test reads like a scenario, not like a ceremony of calling an API.
If you make a helper like createArticleWithTitleBlankAndExpect400, you hide the meaning, and the test becomes “magical”: to understand what it does, you have to go into the helper. It is like watching a movie where every second scene has to be read on Wikipedia — technically possible, but the pleasure is questionable.
6. Parameterized negative tests
Parameterized tests are cool, but they are tricky. They can easily turn a clear set of scenarios into “digital mush,” where you see a set of inputs but lose the meaning. The key principle is this: you can parameterize only when the status, the meaning of the error, and the response contract stay the same. Otherwise you will run one test that sometimes expects 400, sometimes 404, sometimes 409 — and that is no longer a test, but a mini-quest.
A good example of parameterization is “blank title” in two forms: an empty string and a string of spaces:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ParameterizedTest
@ValueSource(strings = {"", " "}) // two forms of "blank": completely empty and spaces only
void shouldReturn400WhenTitleIsBlank(String title) throws Exception {
// Change exactly one factor relative to the valid JSON: the title field.
String body = """
{"title":"%s","summary":"Summary","body":"Body","category":"JAVA"}
""".formatted(title);
// Check the web boundary: the request should fail with 400, without reaching the business logic.
postJson(mvc, "/api/editor/articles", body)
.andExpect(status().isBadRequest());
}
This is one meaning, one error class, one status. The parameters do not “hide” the idea; they only remove duplication. The test reader still understands that we are checking the @NotBlank rule for title.
A bad example, described in words, would look like this: “let us run parameters id="abc", id="999", id="42" and expect different statuses.” In reality, that is three different stories: type mismatch (400), not found (404), conflict (409) — and each should have its own test (or its own @Nested block). Otherwise you are fooling yourself: the test becomes “universal,” but stops being readable.
7. Mocks in the negative matrix: two basic patterns
In negative MVC tests there are usually only two types of interaction with the service. The first type is that the service must not be called at all, because the request failed at the web boundary (validation, malformed JSON, type mismatch). The second type is that the service is called, but it throws a controlled exception, and we verify that @ControllerAdvice translates it into the correct HTTP status + ApiProblem.
Pattern A: the request is bad → do not touch the service
import static org.mockito.BDDMockito.then;
@Test
void shouldStopBeforeServiceWhenRequestIsInvalid() throws Exception {
// Break one DTO rule: title is empty.
String body = """
{"title":"","summary":"Summary","body":"Body","category":"JAVA"}
""";
// Check that the web layer rejects the request on input.
postJson(mvc, "/api/editor/articles", body)
.andExpect(status().isBadRequest());
// Key boundary check: we must not reach the business layer.
then(service).shouldHaveNoInteractions();
}
Here the test proves the boundary: the web layer filters out garbage on its own, and the business layer does not participate. This matters because otherwise you will “fix” validation in the service, and end up with a different error format and a harder-to-maintain API.
Pattern B: request → service throws a business error → advice maps it to ApiProblem
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@Test
void shouldReturn409WhenInvalidStatusTransition() throws Exception {
// The request is valid, so we reach the service.
// But the service simulates a business conflict: the status transition is invalid.
given(service.submitForReview(42L))
.willThrow(new InvalidStatusTransitionException("Already submitted"));
// Here we test the contract for the client: HTTP status + key error field.
mvc.perform(post("/api/editor/articles/42/submit"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.errorCode").value("INVALID_STATUS_TRANSITION"));
}
The point is that we are not testing “why it is already submitted,” but what the error looks like for the client. Internal business logic (why the transition is invalid) is tested either by unit tests for the policy/service, or by other layers. Here the contract matters: status + error payload.
8. Mini templates for negative scenarios
When you start thinking in matrices, it suddenly turns out that many negative tests are the same “skeleton,” just with different inputs. That is normal: Spring MVC also processes errors through standard routes. Below are a few “mini templates” (as ideas only) that help you write tests faster and more consistently.
For validation failure, you almost always create a correct JSON, violate one DTO rule, expect 400, and, if the contract requires it, check violations. It is also useful to prove that the service was not called.
For malformed JSON, you provide a body that cannot be parsed, expect 400, and it is also useful to make sure the service was not called. Here it is often not worth requiring violations — this is not field-level validation, but a read error.
For type mismatch, you break the path (for example, abc instead of Long) or a query parameter, expect 400, and do not touch the service. This is a separate class of errors: the resource has not even been looked up yet, we have not reached the business logic.
For 404, you build a correct request, and mock the service so that it throws ArticleNotFoundException. Then we check 404 and errorCode = "ARTICLE_NOT_FOUND". This is exactly the case where the test should be “about translating an exception,” not about finding an article.
For 409, we do the same, but the exception is InvalidStatusTransitionException, and the expectation is 409 with the corresponding error code.
For 500 (unexpected), in a training project one test for the whole class of problems is usually enough: the service throws RuntimeException, advice turns it into 500 with a safe payload. This test is not there because we love 500, but because a random NPE should not turn into “we send a stack trace to the client.”
To anchor this in your head, a short logic diagram is useful (not as a “REST rule,” but as a practical hint for the test):
flowchart TD
A["Request arrived"] --> B{"Was input read successfully?"}
B -- no --> S400["400: malformed / type mismatch"]
B -- yes --> C{"Is the DTO valid?"}
C -- no --> V400["400: validation + violations"]
C -- yes --> D{"Was the resource found?"}
D -- no --> N404["404: not found"]
D -- yes --> E{"Does the state allow the action?"}
E -- no --> K409["409: conflict"]
E -- yes --> OK["2xx: happy path"]
If you start from this tree, it becomes easier not to confuse where 400 is, where 404 is, and where 409 is. And in the end, the tests become more even in style.
9. When to add a new negative test
At some point, the temptation appears: “Let us check all fields, all lengths, all combinations.” And that is where test inflation begins. A negative suite can grow tenfold, but the increase in confidence will be small, because you will be duplicating the same risk from different angles.
The practical approach is this: a new test is worth adding when it covers a new category of risk, not a new variation of an already covered one. If you have already checked that title cannot be blank, then another 15 tests for " " vs "\t" vs "\n" usually do not add new value. But a test for missing category is a different category (missing required field), and it is useful. Similarly, if you have already proven that submit returns 409 for an invalid transition, then a second test for “different error text” is usually weak, because the message can change without changing the meaning.
The matrix helps here because you can see the “empty cells.” If you have validation on create, but no type mismatch check on update, that may be a real gap (for example, /api/editor/articles/abc should consistently return 400). If you have 404 on update, but no 404 on submit, that is also a gap: “submit a non-existent article” is a realistic scenario.
And one more down-to-earth heuristic: if you cannot explain in 10 seconds which risk a new test covers, it is probably unnecessary or needs to be reformulated. Negative tests should not be about “everything,” but about specific classes of failures.
10. Typical mistakes when building a negative matrix
Mistake #1: dump all negative cases into one flat class.
At first this seems fast: “let us just write 30 methods in a row.” But after a week you will get a file that cannot be read. Grouping by endpoint with @Nested works like a table of contents: you immediately see “create,” “update,” “submit,” and do not mix up cases from different parts of the API.
Mistake #2: parameterize what cannot be parameterized.
A parameterized test is good when you have one meaning and one status. If you try to cover 400, 404, and 409 with one parameterized method, you hide the semantics of the statuses and make the test less readable. Three simple tests are better than one “smart” one that nobody understands.
Mistake #3: check only the status and ignore the error contract.
The status is important, but for the API client it is often critical that ApiProblem has errorCode and, in the case of validation, violations. If you only check 400, then a change in the error structure can break clients while your tests stay green. Balance is needed: do not check every letter, but you should fix the key contract fields.
Mistake #4: forget to verify that the service was not called on input errors.
Without shouldHaveNoInteractions(), you do not prove the web-layer boundary. It may turn out that the controller still calls the service, and the service throws an exception, and you accidentally get 400. The test will be green, but architecturally you have already “dragged garbage” deeper into the application.
Mistake #5: hide HTTP inside overly smart helpers.
If a helper completely hides the URL, request body, and headers, the test stops being an HTTP contract check and becomes a call to “some kind of magic.” Remove only the routine (Content-Type, Accept), but keep the key things visible: endpoint and payload.
GO TO FULL VERSION