Spring Boot by Building · Chapter 5
The last walkthrough. We test the service in isolation by handing it a fake repository we
control, then test the controller's HTTP layer alone. By the end, "mock", "stub", and @MockitoBean are
concrete tools — and you'll see that the constructor injection from Chapter 3 is precisely what makes testing
painless.
Study the same Proving it works: Mockito & slice tests explanation with a different purpose: build and verify it in Learn deeply, or retrieve and defend it in Prepare for interviews.
The chapter below remains the canonical explanation for both goals. Keep its model, examples, and trade-offs shared; only the deliberate practice changes with the route.
Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.
Without notes, explain the boundary this chapter protects, one concrete implementation decision, and the cost of a tempting shortcut. Then answer one follow-up using the chapter’s own example.
To test one layer, you replace its neighbours with stand-ins you control. To test the
TaskService's rules, you don't want a real database — you want a fake repository that returns
exactly what your test needs, so a failure can only mean the service is wrong. That fake is a
mock, and Mockito is the tool that makes one in a line. This only works because your service
receives its repository (DI) instead of new-ing it — testability was designed in three
chapters ago.
"Mock" gets used loosely for any fake object, but there's a small family. You only need to recognise them; in practice Mockito covers the two that matter.
| Double | What it does | Everyday use |
|---|---|---|
| Stub | Returns canned answers you set up. | "When asked for task 1, return this task." Most tests. |
| Mock | A stub that also records calls, so you can assert it was called. | "Verify save() was called once." Mockito's default. |
| Fake | A real-but-simplified working implementation. | An in-memory DB like our Chapter 3 Map. |
| Spy | Wraps a real object, records calls, can override some. | Rare; partial mocking. |
| Dummy | Passed to fill a parameter, never used. | A placeholder argument. |
You don't test a trainee pilot's decisions in a real storm. You put them in a simulator where you dial up exactly the conditions you want and watch what they do. A mock repository is the simulator: you decide what "the database" returns, then check the service reacts correctly. Fast, safe, repeatable.
The web starter already brought in spring-boot-starter-test — JUnit 5, Mockito, and AssertJ are all
present. Create the test in the parallel src/test tree (never in main):
package com.example.taskflow.service;
import com.example.taskflow.model.Task;
import com.example.taskflow.repository.TaskRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) // turns on Mockito for this test class
class TaskServiceTest {
@Mock
private TaskRepository repository; // a fake repository Mockito creates
@InjectMocks
private TaskService service; // real service, with the mock injected in
@Test
void create_savesANotDoneTask() {
// arrange: when the service calls save(...), echo the argument back
when(repository.save(any(Task.class))).thenAnswer(inv -> inv.getArgument(0));
// act
Task result = service.create("Buy milk");
// assert: the service applied its rule (done starts false)
assertThat(result.getTitle()).isEqualTo("Buy milk");
assertThat(result.isDone()).isFalse();
// and it actually asked the repository to save exactly once
verify(repository, times(1)).save(any(Task.class));
}
@Test
void getById_whenMissing_throws404Exception() {
// arrange: the fake DB has nothing for id 99
when(repository.findById(99L)).thenReturn(Optional.empty());
// act + assert: the service's "must exist" rule fires
assertThatThrownBy(() -> service.getById(99L)).isInstanceOf(TaskNotFoundException.class);
}
}
@Mock TaskRepository repository → Mockito builds a fake that, by default, returns null/empty
for everything until you say otherwise.@InjectMocks TaskService service → Mockito creates a real service and passes the mock
into its constructor. This is the exact seam DI gave you.when(...).thenReturn(...) = stubbing (set a canned answer).
verify(...) = checking a call happened (the "mock" half). Those two verbs are
90% of Mockito.This test starts no server and opens no database — it's plain Java objects, so it runs in milliseconds. It can only fail if the service's logic is wrong, which makes a red test a precise finger-point. That isolation is the payoff of keeping rules in the service and storage behind an interface.
when(...).thenReturn(...) vs verify(...): the first sets up behaviour before
you act; the second asserts a call happened after. If a value comes back null in a test,
you forgot to stub it. If you meant to check "did it save?", that's verify, not
when.
To test the controller you care about a different thing: does the URL route correctly, is the JSON shaped right,
is the status code correct? You don't want the real service or DB — so you mock the service and use
@WebMvcTest, which boots only the web layer (fast, focused).
package com.example.taskflow.controller;
import com.example.taskflow.model.Task;
import com.example.taskflow.service.TaskService;
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.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(TaskController.class) // boot ONLY this controller + the web plumbing
class TaskControllerTest {
@Autowired
private MockMvc mockMvc; // a fake HTTP client to fire requests at the controller
@MockitoBean
private TaskService service; // the controller's dependency, mocked
@Test
void getAll_returnsJsonArray() throws Exception {
when(service.getAll(null)).thenReturn(List.of(new Task(1L, "Buy milk", false)));
mockMvc.perform(get("/tasks")).andExpect(status().isOk()).andExpect(jsonPath("$[0].title").value("Buy milk")).andExpect(jsonPath("$[0].done").value(false));
}
@Test
void getOne_missing_returns404() throws Exception {
when(service.getById(99L)).thenThrow(new com.example.taskflow.service.TaskNotFoundException(99L));
mockMvc.perform(get("/tasks/99")).andExpect(status().isNotFound());
}
}
@WebMvcTest(TaskController.class) → loads just the web slice for that controller — no service,
no repository, no database. Fast and targeted.MockMvc → fires fake HTTP requests in-process (no real port) and lets you assert on
the response.@MockitoBean TaskService → like @Mock, but it also puts the fake into Spring's
context so the controller receives it. (The Spring-aware sibling of @Mock.)jsonPath("$[0].title") → reach into the JSON response and assert on a field. This is how you
check response shape.Service rules → plain Mockito unit test (@ExtendWith(MockitoExtension)).
Controller routing/JSON/status → @WebMvcTest slice with
@MockitoBean. Repository derived queries → @DataJpaTest against a real H2 (tests the
actual SQL). Match the test type to the layer, and each stays small.
Four tests, no database started, sub-second. Break something on purpose — change the service so new tasks start
done = true — and watch create_savesANotDoneTask go red. That red line naming the exact
broken rule is what tests buy you.
delete checks existence firstGoal. Prove that deleting a missing task throws (404) and does not call
the repository's deleteById.
@Test
void delete_missing_throwsAndNeverCallsRepoDelete() {
when(repository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.delete(99L)).isInstanceOf(TaskNotFoundException.class);
verify(repository, never()).deleteById(anyLong()); // it bailed before deleting
}
Self-check: verify(..., never()) asserts a call did not happen —
the mirror image of times(1). This encodes the rule "check, then act" as a test.
update on an existing taskGoal. A unit test proving service.update(1, "New title", true)
returns a task with the new title and done=true, and calls save once.
findById(1L) to return an existing task.save(...) to echo its argument.verify(...).save(...) once.when(repository.findById(1L)).thenReturn(Optional.of(existing))
and when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)).@Test
void update_changesTitleAndDone() {
Task existing = new Task(1L, "Old", false);
when(repository.findById(1L)).thenReturn(Optional.of(existing));
when(repository.save(any(Task.class))).thenAnswer(inv -> inv.getArgument(0));
Task result = service.update(1L, "New title", true);
assertThat(result.getTitle()).isEqualTo("New title");
assertThat(result.isDone()).isTrue();
verify(repository, times(1)).save(any(Task.class));
}
If your entity uses the Lombok @AllArgsConstructor,
new Task(1L, "Old", false) works directly. Otherwise build it with setters.
Testing on one page
when(...).thenReturn(...) (stub) and verify(...) (assert a
call)@ExtendWith(MockitoExtension.class) + @Mock repo + @InjectMocks service@WebMvcTest(TaskController.class) + MockMvc + @MockitoBean TaskServicestatus().isOk()/isNotFound(), JSON with jsonPath(...)new.@WebMvcTest
(controller) · @DataJpaTest (repository queries).verify, not when.src/test; Mockito never ships in your running app.Chapter 6 — Logging. One cross-cutting concern before we change gear: give the app a proper running commentary with SLF4J, control the verbosity from config, and see how to swap in Log4j2 — the same "code to the facade, configure the engine" lesson you met with databases.
Current state: owner-scoped behaviour works manually. Outcome: focused service and web-layer tests. Prerequisite: constructor injection. Java bridge: interfaces let tests replace only a collaborator. Estimate: 40 minutes.
Write a service test for a rule and a controller test for an HTTP response. The artifact is a repeatable test suite, not a screenshot.
./mvnw test is green.Likely files: src/test/.../TaskServiceTest.java, src/test/.../TaskControllerTest.java. Suggested commit: test: cover taskflow service and api seams.
Before opening the solution: name the bug each test would catch and run it red once.
The next TaskFlow workshop makes the new behaviour diagnosable in a running service.