Spring Boot Course · Lesson 10
Tests are what let you change code without fear of breaking it. We start where testing should: the fast, isolated base — verifying your BookService's logic with no Spring, no database, no web server. Just the class, some fakes, and assertions that run in a few seconds. This is also where Lesson 3 finally pays off: because the service takes its dependencies through its constructor, testing it is as simple as new BookService(...) with stand-ins.
Four tests that pin down BookService's behaviour: it returns a book when one exists, throws when it doesn't, wires a new book to the right authors before saving and returns what save gave back, and refuses to save when any author is missing. You'll meet the standard Java testing toolkit — JUnit 5, Mockito, AssertJ — and the Arrange–Act–Assert rhythm every test follows. None of the tests starts Spring or a database, so they run in moments.
Think of your tests as a pyramid: a broad base of unit tests (one class, no infrastructure, milliseconds), a thinner middle of slice tests (one layer with a little Spring), and a few end-to-end tests at the top (the whole app). You want most of your tests at the base — they're fast, they pinpoint exactly what broke, and they don't need a database.
BookService is the ideal first target: it holds the real decisions (find-or-throw, look up the authors before saving), and it depends only on two repositories. In a unit test we don't use the real repositories — that would drag in a database. We hand the service test doubles: fake repositories we control, so we can say "pretend this id exists" or "pretend it doesn't" and check how the service reacts.
Test code lives under src/test/java, mirroring the package of the class it covers — so BookService (in com.example.library.book) is tested by BookServiceTest in the same package under src/test/java. Three libraries do the work, and all three arrive with Spring Boot's test starter that's already on your classpath — nothing to add:
@Test methods, lifecycle hooks like @BeforeEach, the runner.save called?").assertThat(result).isSameAs(book).Here's the whole test class skeleton plus the first case. Read the annotations top-down:
package com.example.library.book;
import java.util.Optional;import java.util.Set;import java.util.UUID;import com.example.library.author.Author;import com.example.library.author.AuthorRepository;import com.example.library.error.NotFoundException;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.ExtendWith;import org.mockito.Mock;import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) // wires Mockito into JUnit 5class BookServiceTest {
@Mock BookRepository books; // fake repositories, controlled by us @Mock AuthorRepository authors;
BookService service;
@BeforeEach void setUp() { service = new BookService(books, authors); // just `new` it with the fakes }
@Test @DisplayName("getById returns the book when it exists") void getById_found() { UUID id = UUID.randomUUID(); Book book = new Book("Clean Code", 2008, Set.of(new Author("Robert C. Martin"))); when(books.findById(id)).thenReturn(Optional.of(book)); // Arrange
Book result = service.getById(id); // Act
assertThat(result).isSameAs(book); // Assert }}@ExtendWith(MockitoExtension.class) plugs Mockito into JUnit 5. Before each test method runs, the extension finds every @Mock field and puts a new mock in it [docs]. Without the extension the fields stay null.@Mock BookRepository books declares a mock: an object that implements BookRepository but runs none of its real code — no Spring Data, no database behind it. Until you stub a method, it returns a default value: null, a primitive's zero or false, or something empty [docs]. On BookRepository that means findById returns an empty Optional, existsById returns false, findAllById returns an empty list, and save returns null — so does findAll(Pageable), because a Page isn't a collection. A void method does nothing. And the mock records every call it receives, with its arguments, for verify to check later (Step 4).when(...) in one test can't leak into the next.new BookService(books, authors) in @BeforeEach builds the class under test by hand, with the two mocks in place of real repositories.service = new BookService(books, authors) — that's the entire setup. No Spring context, no application startup, no database. Because BookService receives its repositories through its constructor (Lesson 3's constructor injection), a test can hand it fakes with a plain new. That is the concrete reason we insisted on constructor injection back then: the class is trivially constructable in isolation. Field injection (@Autowired on a field) would have forced reflection or a Spring context just to build the object.
Every test follows the same three-beat shape, and it's worth keeping the beats visibly separate:
when(...).thenReturn(...).One action per test, one behaviour verified.
The method names here follow one common convention — unitOfWork_scenario (getById_found, create_missingAuthor). The exact style is a team choice; you'll also meet method_state_expectedResult and BDD-flavoured shouldThrow_whenBookMissing. Pick one and apply it consistently. It matters less than it once did, because JUnit 5's @DisplayName carries the full human-readable sentence into the test report and IDE ("getById throws NotFoundException when the book is missing") — so the method name only needs to be a terse, greppable identifier, not the whole description crammed into camelCase.
The real value of unit tests is how cheaply they pin down error behaviour. Scripting the fake to return an empty Optional lets us prove the service throws — no database row to delete, no setup gymnastics:
16 collapsed lines
package com.example.library.book;
import java.util.Optional;import java.util.Set;import java.util.UUID;import com.example.library.author.Author;import com.example.library.author.AuthorRepository;import com.example.library.error.NotFoundException;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.ExtendWith;import org.mockito.Mock;import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;import static org.assertj.core.api.Assertions.assertThatThrownBy;26 collapsed lines
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) // wires Mockito into JUnit 5class BookServiceTest {
@Mock BookRepository books; // fake repositories, controlled by us @Mock AuthorRepository authors;
BookService service;
@BeforeEach void setUp() { service = new BookService(books, authors); // just `new` it with the fakes }
@Test @DisplayName("getById returns the book when it exists") void getById_found() { UUID id = UUID.randomUUID(); Book book = new Book("Clean Code", 2008, Set.of(new Author("Robert C. Martin"))); when(books.findById(id)).thenReturn(Optional.of(book)); // Arrange
Book result = service.getById(id); // Act
assertThat(result).isSameAs(book); // Assert }
@Test @DisplayName("getById throws NotFoundException when the book is missing") void getById_missing() { UUID id = UUID.randomUUID(); when(books.findById(id)).thenReturn(Optional.empty()); // Arrange: nothing there
assertThatThrownBy(() -> service.getById(id)) // Act + Assert .isInstanceOf(NotFoundException.class) .hasMessageContaining(id.toString()); }} assertThatThrownBy runs the lambda, catches whatever it throws, and lets you assert on it — the type here, and that the message names the missing id. Note we assert it contains the id, not the exact wording: the id is the stable, meaningful fact, so the surrounding prose can be reworded without breaking the test — while a wrong id would still be caught. Pinning the full string would make the test brittle for a purely cosmetic change. And when the exact wording genuinely is a contract, you assert it where the client actually sees it — the ProblemDetail body at the web boundary (Lesson 11) — not on an internal exception in a service test. This is the service-level guarantee behind the clean 404 from Lesson 7, now locked down by a test.
savecreate does real work: look up the authors, build the book with them, save, and hand back what save returned. That is two things to prove — the object that goes into save is correct, and the object that comes out of it is the one the caller gets. The return value is an ordinary assertion; for the argument, a Mockito ArgumentCaptor grabs it so we can inspect it:
package com.example.library.book;
import java.util.List;10 collapsed lines
import java.util.Optional;import java.util.Set;import java.util.UUID;import com.example.library.author.Author;import com.example.library.author.AuthorRepository;import com.example.library.error.NotFoundException;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.ExtendWith;import org.mockito.ArgumentCaptor;5 collapsed lines
import org.mockito.Mock;import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;import static org.assertj.core.api.Assertions.assertThatThrownBy;import static org.mockito.ArgumentMatchers.any;import static org.mockito.Mockito.verify;37 collapsed lines
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) // wires Mockito into JUnit 5class BookServiceTest {
@Mock BookRepository books; // fake repositories, controlled by us @Mock AuthorRepository authors;
BookService service;
@BeforeEach void setUp() { service = new BookService(books, authors); // just `new` it with the fakes }
@Test @DisplayName("getById returns the book when it exists") void getById_found() { UUID id = UUID.randomUUID(); Book book = new Book("Clean Code", 2008, Set.of(new Author("Robert C. Martin"))); when(books.findById(id)).thenReturn(Optional.of(book)); // Arrange
Book result = service.getById(id); // Act
assertThat(result).isSameAs(book); // Assert }
@Test @DisplayName("getById throws NotFoundException when the book is missing") void getById_missing() { UUID id = UUID.randomUUID(); when(books.findById(id)).thenReturn(Optional.empty()); // Arrange: nothing there
assertThatThrownBy(() -> service.getById(id)) // Act + Assert .isInstanceOf(NotFoundException.class) .hasMessageContaining(id.toString()); }
@Test @DisplayName("create looks up every author, wires them to the book, and saves") void create_wiresAndSaves() { Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Set<UUID> ids = Set.of(hunt.getId(), thomas.getId()); Book persisted = new Book("from the database", 0, Set.of()); // stands for the instance save returns when(authors.findAllById(ids)).thenReturn(List.of(hunt, thomas)); when(books.save(any(Book.class))).thenReturn(persisted);
Book result = service.create("The Pragmatic Programmer", 1999, ids);
assertThat(result).isSameAs(persisted); // what came out of save
ArgumentCaptor<Book> saved = ArgumentCaptor.forClass(Book.class); verify(books).save(saved.capture()); // what went into save assertThat(saved.getValue().getTitle()).isEqualTo("The Pragmatic Programmer"); assertThat(saved.getValue().getYear()).isEqualTo(1999); assertThat(saved.getValue().getAuthors()).containsExactlyInAnyOrder(hunt, thomas); // both looked-up authors attached }}Start with the return value. With a real database, save hands back the instance Hibernate manages, which is not necessarily the object you passed in. The fake plays that part with persisted, a book only the repository could have produced, and isSameAs — identity, not equals — passes only if the service returned that very object. A create that saves and then returns its own book fails here.
Now the argument. The book the service saves is built inside create, so the test never holds a reference to it — there is no variable to write assertThat(book…) against. The two captor lines are how the test reaches that object after the fact:
ArgumentCaptor.forClass(Book.class) creates an empty, Book-typed container. It captures nothing yet.verify(books) switches the mock into verification mode: the next call on it isn't a real call but a question — "was save called, exactly once, with an argument matching this?" If save never ran, or ran twice, the test fails here.saved.capture() is an argument matcher, like any(Book.class), so any Book satisfies the check — but with a side effect: Mockito copies the actual argument of the matching call into the captor. From then on saved.getValue() is the very Book instance the service handed to save.This works because a mock records every invocation it receives, with its arguments, from the moment it's created — the when(...) stub decides what save returns, the recording happens regardless. The captured book then proves the wiring: title and year passed through, and both Author objects the fake returned were attached before saving.
persisted holds placeholder values. Its title is "from the database" and its year 0 on purpose. isSameAs never looks at the fields, so the values can't affect the result — and because they differ from what the test passes to create, there are visibly two books in the test, each with one job: persisted is checked by identity, the book the service built is checked field by field through the captor.containsExactlyInAnyOrder: the entity holds a Set, so order is meaningless — assert the contents, not a sequence. The assertion compares with equals, and because Author's equals is id-based (Lesson 5), the two elements match the stubbed hunt and thomas by id — which here also happen to be the very same instances the stub returned. Just as important as "it saved the right book" is "it didn't save at all when it shouldn't." If any requested author doesn't exist, create must throw before touching save — and the message must name the id that was missing, not the ones that were found. Mockito's verify(..., never()) asserts a call was never made:
20 collapsed lines
package com.example.library.book;
import java.util.List;import java.util.Optional;import java.util.Set;import java.util.UUID;import com.example.library.author.Author;import com.example.library.author.AuthorRepository;import com.example.library.error.NotFoundException;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.ExtendWith;import org.mockito.ArgumentCaptor;import org.mockito.Mock;import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;import static org.assertj.core.api.Assertions.assertThatThrownBy;import static org.mockito.ArgumentMatchers.any;import static org.mockito.Mockito.never;59 collapsed lines
import static org.mockito.Mockito.verify;import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) // wires Mockito into JUnit 5class BookServiceTest {
@Mock BookRepository books; // fake repositories, controlled by us @Mock AuthorRepository authors;
BookService service;
@BeforeEach void setUp() { service = new BookService(books, authors); // just `new` it with the fakes }
@Test @DisplayName("getById returns the book when it exists") void getById_found() { UUID id = UUID.randomUUID(); Book book = new Book("Clean Code", 2008, Set.of(new Author("Robert C. Martin"))); when(books.findById(id)).thenReturn(Optional.of(book)); // Arrange
Book result = service.getById(id); // Act
assertThat(result).isSameAs(book); // Assert }
@Test @DisplayName("getById throws NotFoundException when the book is missing") void getById_missing() { UUID id = UUID.randomUUID(); when(books.findById(id)).thenReturn(Optional.empty()); // Arrange: nothing there
assertThatThrownBy(() -> service.getById(id)) // Act + Assert .isInstanceOf(NotFoundException.class) .hasMessageContaining(id.toString()); }
@Test @DisplayName("create looks up every author, wires them to the book, and saves") void create_wiresAndSaves() { Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Set<UUID> ids = Set.of(hunt.getId(), thomas.getId()); Book persisted = new Book("from the database", 0, Set.of()); // stands for the instance save returns when(authors.findAllById(ids)).thenReturn(List.of(hunt, thomas)); when(books.save(any(Book.class))).thenReturn(persisted);
Book result = service.create("The Pragmatic Programmer", 1999, ids);
assertThat(result).isSameAs(persisted); // what came out of save
ArgumentCaptor<Book> saved = ArgumentCaptor.forClass(Book.class); verify(books).save(saved.capture()); // what went into save assertThat(saved.getValue().getTitle()).isEqualTo("The Pragmatic Programmer"); assertThat(saved.getValue().getYear()).isEqualTo(1999); assertThat(saved.getValue().getAuthors()).containsExactlyInAnyOrder(hunt, thomas); // both looked-up authors attached }
@Test @DisplayName("create throws NotFoundException naming the missing id, and never saves") void create_missingAuthor() { Author hunt = new Author("Andrew Hunt"); UUID missing = UUID.randomUUID(); Set<UUID> ids = Set.of(hunt.getId(), missing); when(authors.findAllById(ids)).thenReturn(List.of(hunt)); // one of two found
assertThatThrownBy(() -> service.create("The Pragmatic Programmer", 1999, ids)) .isInstanceOf(NotFoundException.class) .hasMessageContaining(missing.toString()) .hasMessageNotContaining(hunt.getId().toString());
verify(books, never()).save(any()); // no half-finished write slipped through }}when(...).thenReturn(...) is stubbing: scripting what a fake returns so the code under test has something to work with (input). verify(...) is behaviour verification: asserting the code called a collaborator — or, with never(), that it didn't (output/interaction). Reach for verify when the effect you care about is an interaction (a save, a delete) rather than a return value.
No database to start, no server to boot — just run the tests:
./mvnw test[INFO] T E S T S[INFO] Running com.example.library.book.BookServiceTestMockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3…[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.485 s -- in com.example.library.book.BookServiceTest[INFO] Running com.example.library.LibraryApplicationTests[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0 s -- in com.example.library.LibraryApplicationTests…[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 1…[INFO] BUILD SUCCESSFour behaviours verified in a few seconds, with zero infrastructure. That speed is the whole point of the pyramid's base — you can run these on every save and know instantly when logic breaks.
The same four tools cover the rest of the service layer — the real win is applying them yourself, not reading more worked examples. One test per line; each line says what to stub and what to assert, you write the code.
The rest of BookService, in the same class:
replace, happy path — stub existsById → true, findAllById → the authors, save → a persisted book; assert the result isSameAs(persisted), then capture the saved book and check its title, year, authors and that its id equals the one you passed in. That id assertion is the one line that separates this test from create_wiresAndSaves: without setId, a PUT would insert a second book.replace, missing book — stub existsById → false; assert a NotFoundException naming the id, verify(authors, never()).findAllById(any()) and verify(books, never()).save(any()). The guard runs before the author lookup and before the write.replace, missing author — stub existsById → true and findAllById → one of the two authors; the same three assertions as create_missingAuthor.create with two ids where one is unknown names exactly that one.delete, happy path — stub existsById → true; verify(books).deleteById(id). No captor needed: you hold the id, so verify compares it directly.delete, missing book — stub existsById → false; assert the NotFoundException, verify(books, never()).deleteById(any()).findAll, no author id — build a page request with Pageable pageable = PageRequest.of(0, 20) (from org.springframework.data.domain, the class the Lesson 8 helper used), call service.findAll(null, pageable), then verify(books).findAll(pageable) and verify(books, never()).findByAuthorsId(any(), any()). To assert the content as well, stub books.findAll(pageable) with new PageImpl<>(List.of(…)) and check result.getContent().findAll, with an author id — the mirror image: verify(books).findByAuthorsId(authorId, pageable) and verify(books, never()).findAll(any(Pageable.class)). The cast is required: JpaRepository declares several findAll overloads, so a bare any() fails to compile with reference to findAll is ambiguous. The method only branches and delegates, so these two tests read like the code — but an inverted condition or a dropped pageable is exactly what they catch, and the repository tests in Lesson 12 never call the service, so nothing else covers that branch.AuthorService, in a new AuthorServiceTest next to it. It takes both mocked repositories, like BookService — note the constructor order, new AuthorService(authors, books):
findAll — stub authors.findAll(pageable) with a PageImpl of two authors; assert result.getContent() holds exactly those two.getById, found and missing — mirror the two book tests.create — stub save → persisted; assert isSameAs(persisted), capture the saved author and check its name.replace, happy path and missing author — as for books, minus the author lookup: the captured author carries your id and the new name; the missing case throws and never saves.delete, happy path — stub existsById → true and books.existsByAuthorsId(id) → false; verify(authors).deleteById(id).delete, missing author — stub existsById → false; assert the NotFoundException, verify(authors, never()).deleteById(any()) and verify(books, never()).existsByAuthorsId(any()) — the 404 check comes before the 409 check.delete, author still has books — the interesting one: stub existsById → true and books.existsByAuthorsId(id) → true; assert a ConflictException naming the id and verify(authors, never()).deleteById(any()) — the Lesson 7 409, pinned down without a database.That pins down the whole service layer, and ./mvnw test still finishes in a few seconds.
@SpringBootTest here?Because we're testing logic, not wiring. @SpringBootTest boots the whole application context (and wants a database) — necessary when you're checking that Spring assembles and configures everything, which is a later lesson. For a class whose behaviour is pure Java over its dependencies, that's all overhead. Mockito's @InjectMocks can even build the subject for you (it picks the biggest constructor and injects the mocks) — but doing it by hand with new BookService(books, authors) keeps the Lesson 3 point in plain sight: nothing magic is required to construct this class.
UnnecessaryStubbingException? MockitoExtension runs in strict mode: every when(...) you write must actually be used by the code path under test. A stub the test never triggers is flagged — delete it, or you're testing the wrong path.NullPointerException on a repository call? A dependency wasn't mocked (missing @Mock), or @ExtendWith(MockitoExtension.class) is missing so the @Mock fields were never initialized.findById(idA) does nothing when the code asks for idB.Answer from memory before revealing.
BookService with a plain new instead of starting Spring?when(...).thenReturn(...) and verify(...)?create_missingAuthor assert verify(books, never()).save(any())?ArgumentCaptor<Book> let you do?Primary source: Baeldung's Getting Started with Mockito (@Mock, @Captor, @InjectMocks) and Mockito with JUnit 5 cover exactly the annotations used here. For the test framework itself, the JUnit 5 User Guide is the authoritative reference, and AssertJ's docs catalogue its fluent assertions.
Glossary: the terms this lesson introduces are in the Glossary, under Lesson 10 — your quick-reference for every term we use.
Architecture decisions: the 5 choices this lesson makes, each with its reason, are in Architecture Decisions, under Lesson 10.
Code: the project at the end of this lesson · what changed in this lesson, exercises included.