1. The create draft → submit scenario as a regression candidate
If you look at the backend through the eyes of a beginner developer, it seems: “Well, what’s so hard about that — two endpoints, two HTTP requests.” But it is exactly in such “simple” places that regressions are most often born, because they affect several layers at once. In ContentHub, the draft creation and review submission scenario is the front door to the entire publication workflow: if this door jams, there is nothing left to test, because the business process never starts.
It is important to agree on terminology early so that later we do not speak like two different people, one about an “annotation” and the other about “pain in prod.”
| Term | Simple explanation | In our scenario |
|---|---|---|
| Regression suite | A small set of expensive end-to-end tests that are run regularly to catch breakages in key behavior | Several tests for the main flows: submit, publish, reject, archive |
| Regression candidate | A scenario that deserves a place in the regression suite because breaking it is noticeable and costly | create draft → submit — breaks fast and hurts |
| Regression checkpoint | An intermediate check inside a long scenario so the test is diagnosable | After create: there is id, status DRAFT; after submit: status IN_REVIEW |
| Observable result | What we can verify from the outside: by HTTP response and/or data state | Statuses, timestamps, fact of saving in the DB |
And now the main point: our regression test should not turn into a museum of every possible check in the world. We have already covered DTO contract details through @JsonTest, and HTTP mechanics and validation through @WebMvcTest. In the regression test, we keep only what gives the signal “the end-to-end flow is alive,” while not turning the test into a fragile wall of text.
2. Scenario boundaries and checkpoint thinking
A long integration test has a typical problem: it fails, and you see red... but you do not understand where exactly the trouble happened. It is like a situation where you go to a cafe, order coffee, dessert, and soup, and they bring you “something else.” The question is: what exactly? Was the coffee cold? Was the dessert salty? Was the soup actually tea? Without checkpoints, you will be guessing.
That is why we define the boundaries of the scenario explicitly. Start — the article does not exist. End — the article exists and is in the IN_REVIEW status (that is, it successfully passed the OK/WARN moderation branch). And between them we place several control points that are checked through observable results.
A small scenario diagram:
flowchart TD
A["POST /api/editor/articles create draft"] --> B["Checkpoint 1 201 Created, id != null, status=DRAFT"]
B --> C["POST /api/editor/articles/{id}/submit submit for review"]
C --> D["Checkpoint 2 status=IN_REVIEW"]
D --> E["Final proof DB read: Article.status=IN_REVIEW"]
Notice an important thing: checkpoints are not “let’s check everything at once.” They are points that help diagnose a failure. If it failed at the first checkpoint, the problem is in creation. If the first one passed but the second failed, the problem is in the submit mechanism, moderation, or the status transition.
3. Test mode: @SpringBootTest + @AutoConfigureMockMvc
When you know two modes — full-context MockMvc and live-server RANDOM_PORT — you may be tempted by the “fairest” one: spin up a real port and run HTTP “like in real life.” But a regression suite is always a compromise between evidence and cost. If every test is “like in real life,” you will quickly end up with a set that runs like a TV series: starts in the morning, finishes by lunch, and then you go for coffee because watching the progress bar hurts.
For the create draft → submit for review scenario, full-context + MockMvc often gives very good ROI. We really verify layer wiring (controller → service → repository → integration adapters), verify serialization, validation, error handling, and a bunch of other things, but we do not pay for a real TCP/HTTP round-trip.
And one more important detail: in regression scenarios we almost always need database access for the final proof. Not to “test the repository” (that was already done with @DataJpaTest), but to confirm the final state of the business object. That is why we will combine HTTP calls and a final check through ArticleRepository.
4. Test class skeleton
When writing a regression test, you want to start with the most “tasty” part right away: just write mvc.perform(...). But it is better to first assemble the skeleton and agree on which behavior branch we are fixing. In the submit flow we have several outcomes (for example, OK/WARN leads to IN_REVIEW, while BLOCK leads to REJECTED). In one specific regression test, we choose one branch, otherwise the test becomes a “combine harvester” with multiple outcomes and becomes unreadable.
The basic regression skeleton here is the same as for other business-flow tests in the suite: @SpringBootTest + @AutoConfigureMockMvc + @ActiveProfiles("test"). The local properties below does not replace the test profile; it simply fixes the needed moderation branch on top of it.
Below is the minimal skeleton. Pay attention to properties: this is a simple way to lock the moderation branch in a test. The exact property name depends on your project, but the idea is this: the test should control external decisions, otherwise it will depend on the “random mood” of the stub.
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;
@SpringBootTest(properties = "contenthub.moderation.test-decision=OK") // Fix the moderation branch locally on top of the test profile
@AutoConfigureMockMvc // Start the full Spring context and configure MockMvc without a real port
@ActiveProfiles("test") // General test environment for the regression suite
class SubmitForReviewRegressionTest {
@Autowired
MockMvc mvc; // Main tool: make HTTP requests to controllers and check responses
}
If you still have ArticleRepository, we will add it a little later — it will be needed for the final state check. And yes, technically we could check the final result through GET /api/editor/articles/{id}, but reading from the repository is usually shorter and faster, and we care about the signal about the saved state.
5. Checkpoint 1: create draft and id
Creating a draft is the first step of the workflow, and this is exactly where tests often start to “grow hair”: someone checks all response fields, someone tries to stuff half the domain into it, someone accidentally starts testing the JSON contract again. We do the opposite: send a minimally valid request, check only the key success signs, extract the id, and move on.
First we add the repository and the necessary imports for assertions. Imports can stay neat — regression should not look like an encyclopedia.
import org.junit.jupiter.api.Test; // Test annotation (use JUnit 6 in the project)
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void createDraft_thenSubmit_movesArticleToInReview() throws Exception {
// We will fill in Arrange/Act/Assert below: this is just the scenario skeleton for now
}
Now we need a createDraft(...) helper that makes an HTTP request, checks the basic success signs, and returns the id. Important point: a helper is acceptable here because it removes transport noise (JSON parsing), but it does not hide what exactly we are doing.
private Long createDraft(String title) throws Exception {
// Prepare minimally valid JSON: only what is needed to create a draft
String json = """
{"title":"%s","summary":"Short summary","body":"Text","category":"java"}
""".formatted(title);
// Send POST create draft and fix Checkpoint 1: HTTP 201 + status=DRAFT
String body = mvc.perform(post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON) // Tell the controller this is JSON
.content(json)) // Request body
.andExpect(status().isCreated()) // Checkpoint: the draft was really created
.andExpect(jsonPath("$.status").value("DRAFT")) // Checkpoint: business status on the first step
.andReturn().getResponse().getContentAsString(); // Grab the response body to extract the id
// Return the id of the created article: it will be needed for the submit step
return ((Number) com.jayway.jsonpath.JsonPath.read(body, "$.id")).longValue();
}
And one more boundary of scope. In this scenario, we prove the business flow itself and its effects on data: the draft was created, then it moved to IN_REVIEW. We assume the request is made by a valid editor; we do not drag the 401/403 matrix, roles, and owner-based access into every such regression test, otherwise the suite core will quickly bloat.
Now a couple of remarks that save a lot of nerves. If editor endpoints are protected in your project and you get a 401, do not panic and suspect a “broken controller.” Usually it means you simply did not pass authentication. The easiest option is to add basic auth or a test-user postprocessor to the request. For example, you can add .with(httpBasic("editor", "editor-pass")) to the mvc.perform(...) line if you use HTTP Basic. If security is disabled in the test profile, this is not needed — but the principle remains: a regression test should clearly “introduce itself” as the user who can actually perform the action.
Why do we check exactly status=DRAFT, and nothing else? Because “the draft was created” is the business result that affects the later workflow. Checking all response fields in a regression test is repeating the JSON layer and web slice, which we already paid for earlier.
6. Checkpoint 2: submit and the IN_REVIEW status
Now the interesting part begins: the second step of the scenario must actually go through several layers and change the article state. This is often where regressions appear after a “small refactor”: someone changed a policy, someone renamed an enum, someone forgot to save submittedAt, and someone “just a little” tweaked the moderation integration.
Let us make a submitForReview(id) helper and then do the final check through the repository. The submission itself may return the updated article or just 200 OK — in both cases we need two pieces of evidence: a successful HTTP response and saved state in the DB.
private void submitForReview(Long id) throws Exception {
// Second step of the scenario: submit the already created article for review
mvc.perform(post("/api/editor/articles/{id}/submit", id))
.andExpect(status().isOk()); // Checkpoint: the command was accepted and processed without error
}
Now we will add the repository and check the final result. Here we will add AssertJ because it is more readable than a set of assertEquals, and we have already agreed to use it as the course's base style.
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
@Autowired
ArticleRepository articleRepository; // Final proof is done by reading from the DB
@Test
void createDraft_thenSubmit_movesArticleToInReview() throws Exception {
// Act: create a draft
Long id = createDraft("Intro to Spring");
// Act: submit for review
submitForReview(id);
// Assert: read the state from the DB again and check the final business status
Article article = articleRepository.findById(id).orElseThrow();
assertThat(article.getStatus()).isEqualTo(ArticleStatus.IN_REVIEW);
}
If you want to strengthen the proof (and at the same time not slide into “let's check everything”), you can add a check that submittedAt is set. We do not compare the exact time, because it often brings more problems than benefits if you did not fix the Clock in the test profile. But checking “not null” is usually an excellent checkpoint.
assertThat(article.getSubmittedAt()).isNotNull(); // Additional checkpoint: the submit fact is recorded in the data
In the end, you get a good end-to-end signal: the system accepted the command to create a draft, returned an identifier, then accepted the command to submit for review, and really moved the article to the IN_REVIEW status. This is exactly what a regression test should prove: not “we called a method,” but “the system completed the path.”
7. Readability of a regression test
When a test becomes “end-to-end,” the temptation appears to build a small framework around it. Today you make createDraft(...), tomorrow — submit(...), the day after tomorrow — approve(...), then it becomes a DSL givenDraft().whenSubmit().thenStatusIs(...), and suddenly you have a second Spring inside the tests. This is almost always bad for learning and often bad for a real project, because a test failure becomes hard to explain to a newcomer.
A healthy compromise looks like this: helpers should remove routine and repetition, but keep the scenario steps visible. The test method should read like a mini-story: “created → submitted → checked.” Helper names should be domain-based, not technical. createDraft sounds like a business operation. postToEditorControllerAndParseJsonId sounds like punishment.
Sometimes it is useful to split the test into three parts of AAA (Arrange–Act–Assert), but without fanaticism. In a regression test, “Arrange” is usually the initial state (ours is almost empty), “Act” is two HTTP requests, “Assert” is the final status check. If you make that obvious, the test will be easier to maintain, and it will be easier to understand what broke when it turns red.
8. Typical mistakes in a regression test
Mistake #1: make one mega-test “for everything”.
Sometimes a student (or a developer, which is scarier) tries to cram creation, editing, submit, approve, and even public API checks into one method. As a result, the test becomes long, fragile, and poorly diagnosable: it can fail anywhere, and you spend time not on finding the defect, but on archaeology. In a regression suite, it is better to keep one test — one main flow with a clear ending.
Mistake #2: check absolutely all JSON response fields.
It looks like care for quality, but most often it duplicates cheaper test levels. You have already stabilized the DTO JSON contract with @JsonTest, and the web details — in the MVC slice. A regression test only needs a few fields that prove the meaning: id, status, maybe slug or submittedAt. The less noise there is, the higher the value of the signal.
Mistake #3: rely only on HTTP statuses and do not reread the state.
200 OK after submit does not guarantee that the article really moved to the right status. Somewhere an error might have been swallowed, somewhere part of the workflow might not have run, somewhere transaction boundaries might have changed. A final check through a repeated read (repository or a separate GET) turns the test from “we pressed a button” into “the system changed state.”
Mistake #4: mix different moderation branches in one test.
Submit-for-review in ContentHub has alternative outcomes. If in the same test you try to cover both OK and BLOCK, the test turns into two scenarios at once, and the failure becomes less obvious. Keep one branch — one test. There will be another test for the other branch, with different preparation and different expectations.
Mistake #5: hide key assumptions in overly clever helper methods.
A helper is good as long as it reduces noise. But if the helper starts to create categories, prepare users, change properties, and then somehow perform submit “inside itself,” the test stops being a readable scenario. In a regression suite, a little more explicitness is better than a little more magic. Yes, this is that rare case where “clear” is more important than “beautiful.”
GO TO FULL VERSION