Spring Boot by Building · Chapter 5

Proving it works — Mockito & slice tests

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.

Chapter 5 · We do — fully guided · Hands-on ~40 min · Tests live in src/test


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.

Learning goal

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.

// the big idea

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.

DoubleWhat it doesEveryday use
StubReturns canned answers you set up."When asked for task 1, return this task." Most tests.
MockA stub that also records calls, so you can assert it was called."Verify save() was called once." Mockito's default.
FakeA real-but-simplified working implementation.An in-memory DB like our Chapter 3 Map.
SpyWraps a real object, records calls, can override some.Rare; partial mocking.
DummyPassed to fill a parameter, never used.A placeholder argument.
// analogy — a flight simulator

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):

NEW · src/test/java/com/example/taskflow/service/TaskServiceTest.java
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);
    }
}
read the three Mockito moves
  • @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.
// why no database, no Spring, no HTTP here

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.

// the classic Mockito confusion

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).

NEW · src/test/java/com/example/taskflow/controller/TaskControllerTest.java
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());
    }
}
the controller-test vocabulary
  • @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.
// which test for which layer — the rule of thumb

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

$./mvnw test ... TaskServiceTest > create_savesANotDoneTask PASSED TaskServiceTest > getById_whenMissing_throws404 PASSED TaskControllerTest > getAll_returnsJsonArray PASSED TaskControllerTest > getOne_missing_returns404 PASSED BUILD SUCCESS — Tests run: 4, Failures: 0

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

Exercise 5.1 we do — full solution shown

Test that delete checks existence first

Goal. Prove that deleting a missing task throws (404) and does not call the repository's deleteById.

TaskServiceTest.java
@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.

Exercise 5.2 you do

Test update on an existing task

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
You need two stubs: when(repository.findById(1L)).thenReturn(Optional.of(existing)) and when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)).
Show full solution
TaskServiceTest.java
@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

Service unit test

Controller slice test

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, not when.
  • 💡Tests live in src/test; Mockito never ships in your running app.
// next

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 test is green.

Likely files: src/test/.../TaskServiceTest.java, src/test/.../TaskControllerTest.java. Suggested commit: test: cover taskflow service and api seams.

Hint 1
Arrange only the repository behaviour the service needs.
Hint 2
Assert a consumer-visible result, not that a mock exists.
Hint 3
Use the web test for transport behaviour, not service internals.

Before opening the solution: name the bug each test would catch and run it red once.

Reference patch — focused tests
Mock the repository below the service; keep the service real. In the web test, assert the HTTP contract.

Recall before continuing

  1. What belongs in a service test?
  2. What belongs in a controller test?
  3. Why does constructor injection help tests?

The next TaskFlow workshop makes the new behaviour diagnosable in a running service.