Spring Boot Course · Lesson 10

Unit-Testing the Service Layer

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.

The win 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 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.

Why start at the service, with fakes?

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.

Step 1 — The toolkit and where tests live

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:

Step 2 — A first test: found means returned

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
    }
}
This line is the Lesson 3 payoff 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.
Arrange, Act, Assert Every test follows the same three-beat shape, and it's worth writing them as visibly separate: Arrange the world (create data, script the fakes with when(...).thenReturn(...)), Act by calling the one method under test, then Assert the outcome. One action per test, one behaviour verified.
Naming the test methods 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.

Step 3 — The unhappy path: absence throws

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.

Step 4 — Collaboration: the right book reaches 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.

Two deliberate choices in this test

Step 5 — Behaviour: the wrong thing must not happen

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
}
Stubbing vs. verifying — two different jobs 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.

Step 6 — Run them

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.

Your turn: extend the coverage The same four tools cover the rest of the service layer — the real win is applying them yourself, not reading more worked examples:

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.

Why no @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.
If it doesn't behave

Check yourself

Answer from memory before revealing.

Why can this test build BookService with a plain new instead of starting Spring?
What is the difference between when(...).thenReturn(...) and verify(...)?
Why does create_missingAuthor assert verify(books, never()).save(any())?
What does an ArgumentCaptor<Book> let you do?

Read this next

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.