Spring Boot Course · Lesson 10
Tests are the line between a hobby project and a hireable one. 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 about a second. 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.
BookService's behaviour: it returns a book when one exists, throws when it doesn't, wires a new book to the right author before saving, and refuses to save when the author is missing. You'll meet the standard Java testing toolkit — JUnit 5, Mockito, AssertJ — and the Arrange–Act–Assert rhythm every test follows. Verified: Tests run: 4, Failures: 0 in ~1 second.
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 author 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:
src/test/java/com/example/library/book/BookServiceTest.java
package com.example.library.book;
import java.util.Optional;
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 5
class 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);
when(books.findById(id)).thenReturn(Optional.of(book)); // Arrange
Book result = service.getById(id); // Act
assertThat(result).isSameAs(book); // Assert
}
} 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.
when(...).thenReturn(...)), Act by calling the one method under test, then Assert the outcome. One action per test, one behaviour verified.
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:
BookServiceTest.java — a second test (add the import at the top, the method in the class)
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@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.
save create does real work: look up the author, build the book, attach the author, save. We want to prove the object handed to save is correct. A Mockito ArgumentCaptor grabs that argument so we can inspect it:
BookServiceTest.java — a third test (imports at the top, method in the class)
import org.mockito.ArgumentCaptor;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
@Test
@DisplayName("create looks up the author, wires it to the book, and saves")
void create_wiresAndSaves() {
UUID authorId = UUID.randomUUID();
Author author = new Author("Robert C. Martin");
when(authors.findById(authorId)).thenReturn(Optional.of(author));
when(books.save(any(Book.class))).thenAnswer(invocation -> invocation.getArgument(0)); // save returns its input
service.create("Clean Code", 2008, authorId);
ArgumentCaptor<Book> saved = ArgumentCaptor.forClass(Book.class);
verify(books).save(saved.capture()); // grab what was passed to save
assertThat(saved.getValue().getTitle()).isEqualTo("Clean Code");
assertThat(saved.getValue().getYear()).isEqualTo(2008);
assertThat(saved.getValue().getAuthor()).isSameAs(author); // the looked-up author was attached
}
The captured book proves the wiring: title and year passed through, and the exact Author the fake returned was attached before saving.
thenReturn vs thenAnswer. Use thenReturn(x) when the answer is known up front — we already hold the author. Use thenAnswer(...) when the answer must be derived from the call: the real save hands back the book it saved, and the service builds that book internally, so we echo the argument (invocation.getArgument(0)) rather than return a fixed value.isEqualTo vs isSameAs. Title and year are values we passed in, so we assert value equality (.equals()). The author is the specific object the stubbed lookup returned, and the behaviour under test is "that instance was wired onto the book" — so we assert reference identity (==). It's stricter here too: Author has id-based equals (Lesson 5), so isEqualTo couldn't distinguish the looked-up author from any other object sharing its id.
Just as important as "it saved the right book" is "it didn't save at all when it shouldn't." If the author doesn't exist, create must throw before touching save. Mockito's verify(..., never()) asserts a call was never made:
BookServiceTest.java — a fourth test (import at the top, method in the class)
import static org.mockito.Mockito.never;
@Test
@DisplayName("create throws NotFoundException and never saves when the author does not exist")
void create_missingAuthor() {
UUID authorId = UUID.randomUUID();
when(authors.findById(authorId)).thenReturn(Optional.empty()); // author not found
assertThatThrownBy(() -> service.create("Clean Code", 2008, authorId))
.isInstanceOf(NotFoundException.class);
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.BookServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.121 s -- in com.example.library.book.BookServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS Four behaviours verified in about a second, 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.
BookService.replace and delete — the missing-book throw (stub existsById to return false), the missing-author throw (for replace), and the save / deleteById verification. Same patterns as above.AuthorService — mirror the found/missing getById tests, and verify the save in create. Just one mocked repository this time.findAll is the exception: it's pure delegation (branch, call a repository), so a unit test would mostly re-assert the obvious. Leave it for the data-slice tests with a real query in Lesson 12.
@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.
New vocabulary from this lesson lives in the Glossary — your quick-reference for every term we use.