Spring Boot Hello World Part 2
Java is still one of the most widely used programming languages in the world, and Spring Boot is the de-facto framework for building production-ready services with it. Surprisingly, even though I’ve worked with Java before, I had never actually touched Spring Boot.
That changes today.
In this series, we’ll build a simple CRUD API for tasks step by step.
In Part 1 we built a simple CRUD API for tasks using Spring Boot. In this part, we’ll add fast, isolated web tests for the controller using @WebMvcTest and MockMvc. These tests spin up only the Spring MVC slice (no full application context or server), so they’re quick and focused.
👉 Source for this part: Git repository (part 2 branch).
👉 Parts:
- Part 1: Basics — install the tools and build a simple REST API.
- Part 2 (this post): Add unit tests for our endpoints.
- Part 3: Store tasks in a real NoSQL database (Redis).
Testing architecture
Scope / Style | What It Loads | What It Skips | Speed | Use For | Typical Annotations | Mocking Needs |
---|---|---|---|---|---|---|
MVC slice | Spring MVC layer: @Controller /@RestController , @ControllerAdvice , Jackson, validation, message converters | Full auto-config, embedded server, security (unless added), data layer, scheduling, messaging | Fast | Controller I/O, request/response shapes, status codes, JSON payloads | @WebMvcTest(YourController.class) , MockMvc | Yes (via @MockBean ) for any collaborators |
Web + full context | Whole application context; auto-config; optional embedded server with webEnvironment | — (loads everything—can be heavy) | Medium–Slow | End-to-end integration, filters/interceptors, security, full wiring | @SpringBootTest , @AutoConfigureMockMvc or TestRestTemplate | Usually no (real beans), or mock external deps |
Standalone MockMvc | Only the controller under test, wired manually | Spring context (no DI), all auto-config | Fastest | Ultra-focused unit tests on controller logic without Spring | MockMvcBuilders.standaloneSetup(new YourController()) | Manual stubs (you construct dependencies) |
Data slice | JPA repositories, entity mapping, transaction mgmt | Web layer | Fast | Repository queries, entity mapping | @DataJpaTest | Not typical |
JSON serialization | Jackson ObjectMapper | Everything else | Fast | DTO <-> JSON correctness, custom serializers/deserializers | @JsonTest | Not typical |
When to choose what?
- Use @WebMvcTest (what we do here) when you want HTTP behavior without booting the entire app.
- Use @SpringBootTest + @AutoConfigureMockMvc when you need filters, security, or full wiring.
- Use standalone MockMvc for pure unit tests where Spring’s dependency injection isn’t needed.
- Use @DataJpaTest for repository queries and database mapping.
- Use @JsonTest for serialization-only scenarios.
Handy snippets
Mock a collaborator in @WebMvcTest:
@WebMvcTest(TaskController.class)
class TaskControllerTests {
@Autowired MockMvc mockMvc;
@MockBean TaskService taskService; // if your controller calls a service
// tests...
}
Full context with MockMvc (integration-style):
@SpringBootTest
@AutoConfigureMockMvc
class HttpIntegrationTests {
@Autowired MockMvc mockMvc;
// tests...
}
Standalone MockMvc (no Spring context):
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
class StandaloneControllerTests {
MockMvc mockMvc = standaloneSetup(new TaskController(/* deps */)).build();
// tests...
}
What we’ll test
- Creating a task (POST /tasks)
- Listing tasks (GET /tasks)
- Fetching a single task (GET /tasks/{id})
- Updating a task (PUT /tasks/{id})
- Deleting a task (DELETE /tasks/{id})
- Proper 404s for non-existing IDs
Dependencies
Add test dependencies to build.gradle
:
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
test {
useJUnitPlatform()
}
Controller tests with MockMvc
Create src/test/java/fi/janihast/springboot_hello_world/TaskControllerTests.java
:
package fi.janihast.springboot_hello_world;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import fi.janihast.springboot_hello_world.controllers.TaskController;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(TaskController.class)
class TaskControllerTests {
@Autowired
private MockMvc mockMvc;
// Helper method to create a task and return its ID
private String createTaskAndGetId(String taskName) throws Exception {
String taskJson = "{\"name\":\"" + taskName + "\"}";
final String[] taskId = new String[1]; // Array to make it effectively final
// Create task and read ID
mockMvc.perform(post("/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content(taskJson))
.andExpect(status().isOk())
.andDo(result -> {
String content = result.getResponse().getContentAsString();
ObjectMapper mapper = new ObjectMapper();
JsonNode json = mapper.readTree(content);
taskId[0] = json.get("task").get("id").asText();
});
return taskId[0];
}
@Test
void shouldCreateTaskAndRetrieveList() throws Exception {
String taskId = createTaskAndGetId("Task to be retrieved");
// Retrieve the list of tasks and check if the created task is present
this.mockMvc.perform(get("/tasks"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.tasks").exists())
.andExpect(jsonPath("$.tasks[?(@.id=='" + taskId + "')].name").exists());
}
@Test
void shouldCreateTaskAndRetrieveIt() throws Exception {
String taskId = createTaskAndGetId("Task to be retrieved");
// Retrieve the task by ID and check its id and name
mockMvc.perform(get("/tasks/" + taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task.id").value(taskId))
.andExpect(jsonPath("$.task.name").value("Task to be retrieved"));
}
@Test
void shouldCreateTask() throws Exception {
String taskJson = "{\"name\":\"Test Task\"}";
// Create the task and verify response
mockMvc.perform(post("/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content(taskJson))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.task").exists())
.andExpect(jsonPath("$.task.name").value("Test Task"))
.andExpect(jsonPath("$.task.id").exists());
}
@Test
void shouldCreateTaskAndDeleteIt() throws Exception {
String taskId = createTaskAndGetId("Task to be deleted");
// Verify the task exists
mockMvc.perform(get("/tasks/" + taskId))
.andExpect(status().isOk());
// Delete the task
mockMvc.perform(delete("/tasks/" + taskId))
.andExpect(status().isOk());
// Verify the task no longer exists
mockMvc.perform(get("/tasks/" + taskId))
.andExpect(status().isNotFound());
}
@Test
void shouldUpdateTask() throws Exception {
String taskId = createTaskAndGetId("Task to be updated");
String updatedTaskJson = "{\"name\":\"Updated Task Name\"}";
// Update the task
mockMvc.perform(put("/tasks/" + taskId)
.contentType(MediaType.APPLICATION_JSON)
.content(updatedTaskJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task.id").value(taskId))
.andExpect(jsonPath("$.task.name").value("Updated Task Name"));
// Retrieve the task and verify the update
mockMvc.perform(get("/tasks/" + taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task.id").value(taskId))
.andExpect(jsonPath("$.task.name").value("Updated Task Name"));
}
@Test
void shouldReturn404ForNonExistentTaskOnRetrieve() throws Exception {
UUID randomId = UUID.randomUUID();
// Attempt to retrieve a non-existent task
mockMvc.perform(get("/tasks/" + randomId))
.andExpect(status().isNotFound());
}
@Test
void shouldReturn404ForNonExistentTaskOnUpdate() throws Exception {
UUID randomId = UUID.randomUUID();
String updatedTaskJson = "{\"name\":\"Updated Task Name\"}";
// Attempt to update a non-existent task
mockMvc.perform(put("/tasks/" + randomId)
.contentType(MediaType.APPLICATION_JSON)
.content(updatedTaskJson))
.andExpect(status().isNotFound());
}
@Test
void shouldReturn404ForNonExistentTaskOnDelete() throws Exception {
UUID randomId = UUID.randomUUID();
// Attempt to delete a non-existent task
mockMvc.perform(delete("/tasks/" + randomId))
.andExpect(status().isNotFound());
}
}
Why @WebMvcTest?
- Loads only MVC infrastructure (controllers, Jackson, validation, etc.)
- Doesn’t start the full app or embedded server → fast
- Perfect for controller I/O behavior and HTTP status checks
If your controller depends on services or repositories, you’d add @MockBean for those. Our controller is self-contained, so no mocks are needed here.
Recap
At this point, we have a fully working test suite:
Test name | Endpoint | Expected outcome |
---|---|---|
shouldCreateTask | POST /tasks | 200 + JSON with id & name |
shouldCreateTaskAndRetrieveList | GET /tasks | List contains created task |
shouldCreateTaskAndRetrieveIt | GET /tasks/{id} | Returns correct id + name |
shouldUpdateTask | PUT /tasks/{id} | Returns updated name |
shouldCreateTaskAndDeleteIt | DELETE /tasks/{id} | 200 + then GET returns 404 |
shouldReturn404ForNonExistentTask | GET/PUT/DELETE/{id} | Returns 404 |
In the next part, we’ll add persistent storage for our tasks.
References
Quick reference
Here’s a quick cheatsheet for running your tests:
Run the whole test suite
gradle test
Run single test class
gradle test --tests "fi.janihast.springboot_hello_world.TaskControllerTests"
Run single test method
gradle test --tests "fi.janihast.springboot_hello_world.TaskControllerTests.shouldCreateTaskAndRetrieveList"