Proving it works: Mockito & slice tests
Test service rules with Mockito and controller HTTP behavior with MockMvc, using modern @MockitoBean for Spring web-slice dependencies.
Assumes you know
After this you should be able to
- Write a service test
- Choose a Spring test slice
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.
Choose your route#
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.
- Write a service test
- Choose a Spring test slice
Shared core#
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.
Learn deeply: build evidence#
Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.
Prepare for interviews: explain the trade-off#
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.
Section 1The family of "test doubles"#
"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.
Section 2Unit-test the service with a mock repository#
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.
Section 3Slice-test the controller's HTTP layer#
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.
Section 4Run the tests#
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.
Section 5Exercises#
Goal. 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.
Goal. A unit test proving service.update(1, "New title", true)
returns a task with the new title and done=true, and calls save once.
- Stub
findById(1L)to return an existing task. - Stub
save(...)to echo its argument. - Assert the returned title/done, and
verify(...).save(...)once.
Hint 1 — the arrange block
when(repository.findById(1L)).thenReturn(Optional.of(existing))
and when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)).Show full solution
@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.
Notebook Summary#
Testing on one page
Doubles#
- Stub = canned answers · Mock = stub + records calls · fake / spy / dummy round out the family
- Mockito's two verbs:
when(...).thenReturn(...)(stub) andverify(...)(assert a call)
Service unit test#
@ExtendWith(MockitoExtension.class)+@Mockrepo +@InjectMocksservice- No Spring, no DB, no HTTP → milliseconds, and failures point straight at the rule
Controller slice test#
@WebMvcTest(TaskController.class)+MockMvc+@MockitoBean TaskService- Assert status with
status().isOk()/isNotFound(), JSON withjsonPath(...)
Box these#
- ⭐Constructor injection = testable by design: you can hand a fake in. This is why we
avoided
new. - ⭐Match test type to layer: Mockito unit (service) ·
@WebMvcTest(controller) ·@DataJpaTest(repository queries). - ⚠Got null in a test? You forgot to stub. Want "was it called?" Use
verify, notwhen. - 💡Tests live in
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.
TaskFlow checkpoint#
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.
Mission: make a regression visible before it ships#
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.
- A service test proves the owner or not-found rule without a database.
- A controller test proves status and JSON shape.
./mvnw testis green.
Likely files: src/test/.../TaskServiceTest.java, src/test/.../TaskControllerTest.java. Suggested commit: test: cover taskflow service and api seams.
Hint 1
Hint 2
Hint 3
Before opening the solution: name the bug each test would catch and run it red once.
Reference patch — focused tests
Recall before continuing#
- What belongs in a service test?
- What belongs in a controller test?
- Why does constructor injection help tests?
The next TaskFlow workshop makes the new behaviour diagnosable in a running service.