Test a Spring Boot REST API

Write unit, slice, and integration tests for a Spring Boot Book Catalog API.

Introduction

30 Second Summary

Every developer has shipped code that seemed to work fine, only to discover a bug later that a simple test could have caught. Testing is what separates a quick prototype from professional software.

In this project, you will build a Book Catalog REST API with Spring Boot and write a complete test suite covering all three layers of the testing pyramid: unit tests, slice tests, and integration tests.

What You'll Build

A Book Catalog REST API with CRUD endpoints for managing books, backed by a test suite that validates business logic, the controller layer, and the full application stack.

By the end of this project, you'll have:

  • A working Spring Boot REST API with a Book entity, repository, service, and controller.
  • Unit tests using JUnit 5 and Mockito, slice tests using @WebMvcTest, and integration tests using @SpringBootTest.
  • A clear understanding of the testing pyramid and when to use each type of test in a professional Spring Boot project.
  • Secret Mission: Add exception handling tests that verify your API returns proper error responses when things go wrong.

Are there any prerequisites?

This project assumes you have basic Spring Boot knowledge (controllers, services, and repositories) and Java fundamentals. You'll need IntelliJ IDEA, JDK 22, and Gradle installed on your machine.

Set Up the Project

Before you can write a single test, you need a working application to test against. A Spring Boot project gives you a pre-configured structure with a build system, dependency management, and a test directory ready to go.

In this step, you'll generate a fresh project using Spring Initializr, configure its database, and confirm everything compiles by running the default test.

In this step, get ready to:
  • Generate a Spring Boot project with the right dependencies.
  • Open and configure the project in IntelliJ IDEA.
  • Verify the project compiles and the default test passes.
Generate the project on Spring Initializr

Spring Initializr is a web tool that generates a ready-to-run Spring Boot project for you. You pick your build system, language, and dependencies, and it hands you a zip file with everything wired together.

  • Open your browser and navigate to start.spring.io.
  • Under Project, select Gradle.
  • Under Language, select Java.
  • Under Spring Boot, verify that 4.1.0 is selected. It should be the default.
  • In the Project Metadata section, change the Artifact field to book-catalog.
  • Leave Group as the default com.example.
  • Set the Java version to 22.

Why book-catalog with a hyphen?

Spring Initializr uses the artifact name to derive both the package name and the main class. With book-catalog, it generates the package com.example.bookcatalog and the main class BookCatalogApplication. The hyphen gives you clean CamelCase naming.

  • Click ADD DEPENDENCIES.
  • Search for Spring Web and select it.
  • Search for Spring Data JPA and select it.
  • Search for H2 Database and select it.

What are these dependencies?

Spring Web gives you the tools to build REST API endpoints. Spring Data JPA handles database operations through JPA (Java Persistence API). H2 Database is a lightweight in-memory database that runs inside your app, perfect for development and testing.

  • Click GENERATE to download the project as a zip file.
  • Extract the zip file to a location you can easily find, such as your Desktop.
Open and configure the project in IntelliJ IDEA

Now that you have the generated project on your machine, you need to open it in IntelliJ IDEA so you can start working with it.

  • In IntelliJ IDEA, click File in the top menu bar.
  • Click Open.
  • Navigate to the book-catalog folder you just extracted and select it.
  • Click OK.

Seeing a trust prompt?

IntelliJ IDEA may ask if you trust the project. Click Trust Project to continue. This is a standard security check for projects opened for the first time.

IntelliJ will start importing the Gradle project. Wait for the progress bar in the bottom-right corner to finish before continuing.

While H2 auto-configures itself, adding explicit database properties keeps the setup clear and predictable for your tests. Let's configure that now.

  • In IntelliJ's Project panel on the left, expand src > main > resources.
  • Double-click application.properties to open it.
  • Add the following database configuration to the file:
# Use an in-memory H2 database for development and testing
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver

# Automatically create and drop tables based on entity classes
spring.jpa.hibernate.ddl-auto=create-drop

# Log SQL statements to the console for debugging
spring.jpa.show-sql=true

What does this configuration do?

  • spring.datasource.url tells Spring to use an in-memory H2 database called testdb. The data lives only while the app is running.
  • spring.jpa.hibernate.ddl-auto=create-drop creates your database tables on startup and drops them on shutdown. This gives you a clean slate every time.
  • spring.jpa.show-sql=true prints every SQL query to the console, which is helpful for understanding what JPA does behind the scenes.
  • Save application.properties by pressing Cmd+S (macOS) or Ctrl+S (Windows).

File not showing in the panel?

Make sure you are expanding the correct path: src > main > resources. If the Project panel is not visible, press Cmd+1 (macOS) or Alt+1 (Windows) to toggle it.

Ask for help: I can't find application.properties in my IntelliJ project panel.

✔️ Awesome, I've got everything!

Great. Make sure you've saved application.properties before moving on.

ⓧ I'd like to double check the full code

Here is the complete application.properties file. Make sure yours matches:

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

Now let's verify the project compiles and the auto-generated test passes.

  • Open the terminal in IntelliJ IDEA by pressing Option+F12 (macOS) or Alt+F12 (Windows).
  • Run the default test suite by running this command:

macOS

./gradlew test

Windows

.\gradlew test

You should see output ending with BUILD SUCCESSFUL and a line confirming 1 test was executed.

Build failed?

  • On macOS or Linux, if you see a "Permission denied" error, the Gradle wrapper script may not be executable. Run chmod +x gradlew in the terminal and try again.
  • Make sure the Gradle import in IntelliJ finished completely. Check the bottom-right corner for any progress bars still running.
  • Verify your JDK is set to version 22 by going to File > Project Structure > Project and checking the SDK field.

Ask for help: My gradlew test command is failing in IntelliJ IDEA. Help me troubleshoot.

Review the project structure

Your project came with a test directory and an auto-generated test class. This is where all your tests will live.

  • In the Project panel, expand src > test > java > com.example.bookcatalog.
  • Double-click BookCatalogApplicationTests to open it.

What is this test file?

Spring Initializr auto-generates this test class with a single @SpringBootTest annotation. It verifies that the application context loads successfully. This is the test that passed when you ran gradlew test a moment ago.

The src/test/java directory mirrors the src/main/java directory. You'll create all your test classes here, in the same package as the code they test. This package alignment lets your tests access package-private members.

Your project is configured and the default test passes. Next up, you'll build the REST API that your tests will cover.

Build the REST API

Your Spring Boot project is set up with all the right dependencies. Now it's time to build the actual REST API that your test suite will cover in the next steps.

Spring Boot REST APIs follow a layered architecture. You'll create four components: a Book entity for the data model, a repository for database access, a service for business logic, and a controller that exposes HTTP endpoints.

In this step, get ready to:
  • Create the Book entity to define the data model.
  • Build the repository, service, and controller layers.
  • Run the application to confirm everything starts without errors.
Create the Book entity

Every database table needs a corresponding Java class in Spring Boot. The Book entity defines the fields your API stores and retrieves.

  • In IntelliJ's Project panel on the left, expand src/main/java/com/example/bookcatalog.
  • Right-click the bookcatalog package and select New, then Java Class.
  • Enter Book as the name and press Enter.
  • Replace everything in the new file with this code:
package com.example.bookcatalog;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

// Marks this class as a JPA entity mapped to a database table
@Entity
public class Book {

    // Primary key with auto-generated values
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String author;
    private String isbn;

    // No-arg constructor required by JPA
    public Book() {
    }

    // Convenience constructor for creating books in code
    public Book(String title, String author, String isbn) {
        this.title = title;
        this.author = author;
        this.isbn = isbn;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getAuthor() {
        return author;
    }

    public void setAuthor(String author) {
        this.author = author;
    }

    public String getIsbn() {
        return isbn;
    }

    public void setIsbn(String isbn) {
        this.isbn = isbn;
    }
}

What does this code do?

  • @Entity tells Spring Boot this class maps to a database table. Each Book object becomes a row in that table.
  • @Id marks the id field as the primary key. @GeneratedValue(strategy = GenerationType.IDENTITY) lets the database auto-generate unique IDs for each new book.
  • The no-argument constructor is required by JPA. The three-argument constructor is a convenience for creating books in your application code.
  • Save Book.java.
  • Check that IntelliJ shows no red underlines in the file. The imports should resolve without errors.

Seeing red underlines?

  • Make sure the import statements use jakarta.persistence, not javax.persistence. Spring Boot 4.x uses the Jakarta namespace.
  • If imports aren't resolving, wait a moment for IntelliJ to finish indexing. You'll see a progress bar at the bottom of the window.
  • Why am I seeing import errors in my Book entity?

✔️ Awesome, I've got everything!

Great. Make sure you've saved Book.java before moving on.

ⓧ I'd like to double check the full code

package com.example.bookcatalog;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String author;
    private String isbn;

    public Book() {
    }

    public Book(String title, String author, String isbn) {
        this.title = title;
        this.author = author;
        this.isbn = isbn;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getAuthor() {
        return author;
    }

    public void setAuthor(String author) {
        this.author = author;
    }

    public String getIsbn() {
        return isbn;
    }

    public void setIsbn(String isbn) {
        this.isbn = isbn;
    }
}
Create the repository, service, and controller

With the entity in place, you need three more components to complete the API. The repository handles database queries, the service contains business logic, and the controller maps HTTP requests to service methods.

  • Right-click the bookcatalog package again and select New, then Java Class.
  • Enter BookRepository as the name.
  • In the Kind dropdown, select Interface, then press Enter.
  • Replace everything in the new file with this code:
package com.example.bookcatalog;

import org.springframework.data.jpa.repository.JpaRepository;

// Extends JpaRepository to inherit CRUD methods for the Book entity
public interface BookRepository extends JpaRepository<Book, Long> {
}

What is JpaRepository?

By extending JpaRepository<Book, Long>, your repository inherits ready-made methods like findAll(), findById(), save(), and deleteById(). You don't need to write a single query.

  • Save BookRepository.java.
  • Create another new Java class in the bookcatalog package named BookService.
  • Replace everything in the new file with this code:
package com.example.bookcatalog;

import java.util.List;

import org.springframework.stereotype.Service;

// Registers this class as a Spring-managed service bean
@Service
public class BookService {

    private final BookRepository bookRepository;

    // Constructor injection wires the repository automatically
    public BookService(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    // Returns all books from the database
    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }

    // Returns a single book or throws if not found
    public Book getBookById(Long id) {
        return bookRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Book not found with id: " + id));
    }

    // Saves a new book to the database
    public Book createBook(Book book) {
        return bookRepository.save(book);
    }

    // Deletes a book by its ID
    public void deleteBook(Long id) {
        bookRepository.deleteById(id);
    }
}

What does the service do?

  • @Service registers this class as a Spring-managed bean so it can be injected into other components.
  • Constructor injection wires the BookRepository into the service automatically. Spring finds the repository bean and passes it in.
  • getBookById uses orElseThrow to throw a RuntimeException if no book matches the ID. This is the logic your unit tests will verify later.
  • Save BookService.java.
  • Check that IntelliJ shows no red underlines. The BookRepository import should resolve because you created that interface in the previous step.

BookRepository not resolving?

  • Make sure BookRepository.java is saved and exists in the same com.example.bookcatalog package.
  • If you still see a red underline, try pressing Ctrl+Space on the underlined name to trigger auto-import.
  • Why can't IntelliJ find my BookRepository?
  • Create another new Java class in the bookcatalog package named BookController.
  • Replace everything in the new file with this code:
package com.example.bookcatalog;

import java.util.List;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

// Marks this class as a REST controller that returns JSON
@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    // GET /api/books - returns all books
    @GetMapping
    public ResponseEntity<List<Book>> getAllBooks() {
        return ResponseEntity.ok(bookService.getAllBooks());
    }

    // GET /api/books/{id} - returns a single book
    @GetMapping("/{id}")
    public ResponseEntity<Book> getBookById(@PathVariable Long id) {
        return ResponseEntity.ok(bookService.getBookById(id));
    }

    // POST /api/books - creates a new book, returns 201
    @PostMapping
    public ResponseEntity<Book> createBook(@RequestBody Book book) {
        Book created = bookService.createBook(book);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    // DELETE /api/books/{id} - deletes a book, returns 204
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
        bookService.deleteBook(id);
        return ResponseEntity.noContent().build();
    }
}

What do the controller annotations do?

  • @RestController marks this class as a REST endpoint that returns JSON by default.
  • @RequestMapping("/api/books") sets the base URL path for all endpoints in this controller.
  • @GetMapping, @PostMapping, and @DeleteMapping map HTTP methods to Java methods.
  • ResponseEntity lets you control the HTTP status code. The POST endpoint returns 201 Created, and the DELETE endpoint returns 204 No Content.
  • Save BookController.java.
  • Check that IntelliJ shows no red underlines across all four files: Book.java, BookRepository.java, BookService.java, and BookController.java.

Seeing compilation errors?

  • Verify all four files are in the com.example.bookcatalog package. Each file's first line should read package com.example.bookcatalog;.
  • If an import is underlined in red, try clicking on it and pressing Alt+Enter to let IntelliJ suggest a fix.
  • Why is my BookController showing compilation errors?

✔️ Awesome, I've got everything!

All three files are in place. Make sure you've saved each one before moving on.

ⓧ I'd like to double check the full code

package com.example.bookcatalog;

import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {
}
package com.example.bookcatalog;

import java.util.List;

import org.springframework.stereotype.Service;

@Service
public class BookService {

    private final BookRepository bookRepository;

    public BookService(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }

    public Book getBookById(Long id) {
        return bookRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Book not found with id: " + id));
    }

    public Book createBook(Book book) {
        return bookRepository.save(book);
    }

    public void deleteBook(Long id) {
        bookRepository.deleteById(id);
    }
}
package com.example.bookcatalog;

import java.util.List;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    @GetMapping
    public ResponseEntity<List<Book>> getAllBooks() {
        return ResponseEntity.ok(bookService.getAllBooks());
    }

    @GetMapping("/{id}")
    public ResponseEntity<Book> getBookById(@PathVariable Long id) {
        return ResponseEntity.ok(bookService.getBookById(id));
    }

    @PostMapping
    public ResponseEntity<Book> createBook(@RequestBody Book book) {
        Book created = bookService.createBook(book);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
        bookService.deleteBook(id);
        return ResponseEntity.noContent().build();
    }
}
Run the application

With all four files saved, it's time to confirm everything compiles and the application starts successfully.

  • Open BookCatalogApplication.java from the Project panel.
  • Click the green play button in the gutter next to the main method.
  • Select Run 'BookCatalogApplication' from the popup menu.

The Run panel opens at the bottom of IntelliJ. You should see a message indicating the application started on port 8080.

What should I see?

Look for a line in the console that says Tomcat started on port 8080 followed by Started BookCatalogApplication. You'll also see H2 SQL statements printed because spring.jpa.show-sql=true is set in your application.properties.

  • Stop the application by clicking the red square Stop button in the top-right corner of the Run panel.

Application won't start?

  • If you see a BeanCreationException, double-check that BookRepository is an interface (not a class) and that it extends JpaRepository<Book, Long>.
  • If port 8080 is already in use, stop whatever process is using it, then try running the application again.
  • Make sure all four files are in the same com.example.bookcatalog package as BookCatalogApplication.java.
  • My Spring Boot application won't start. Help me debug the error.

Why this layered architecture?

Separating your code into entity, repository, service, and controller layers keeps each class focused on one responsibility. The controller handles HTTP, the service handles business logic, and the repository handles data access.

This separation is what makes testing possible. You can test each layer in isolation by mocking the layer below it. That's exactly what you'll do in the next steps.

Your REST API is up and running. Next up, you'll write unit tests to verify the service layer works correctly without loading the full Spring context.

Write Unit Tests for the Service Layer

Your REST API is built and running. Now it is time to prove that the business logic actually works correctly.

Unit tests sit at the base of the testing pyramid. They test individual pieces of logic in complete isolation, without starting the Spring application or connecting to a database.

In this step, get ready to:
  • Create a BookServiceTest class with mock dependencies.
  • Write four test methods covering the service layer's core behavior.
  • Run the tests and verify they all pass in milliseconds.
Create the BookServiceTest class and write your first test

To test BookService in isolation, you need to replace its real BookRepository dependency with a fake (a "mock"). Mockito handles this for you.

  • In the Project sidebar in IntelliJ IDEA, right-click the src/test/java/com/example/bookcatalog package.
  • Select New, then Java Class.
  • Name it BookServiceTest and press Enter.
  • Replace everything in the new file with the following code:
package com.example.bookcatalog;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

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 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.BDDMockito.given;

// Tells JUnit 5 to use Mockito for mock creation
@ExtendWith(MockitoExtension.class)
class BookServiceTest {

    // Creates a fake BookRepository that Mockito controls
    @Mock
    private BookRepository bookRepository;

    // Injects the mock repository into a real BookService instance
    @InjectMocks
    private BookService bookService;

    @Test
    void getAllBooks_returnsListOfBooks() {
        // Arrange: tell the mock repository what to return
        Book book1 = new Book("Clean Code", "Robert Martin", "978-0132350884");
        Book book2 = new Book("Effective Java", "Joshua Bloch", "978-0134685991");
        given(bookRepository.findAll()).willReturn(Arrays.asList(book1, book2));

        // Act: call the service method under test
        List<Book> result = bookService.getAllBooks();

        // Assert: verify the result matches expectations
        assertThat(result).hasSize(2);
        assertThat(result.get(0).getTitle()).isEqualTo("Clean Code");
    }
}

What does this code do?

@ExtendWith(MockitoExtension.class) tells JUnit 5 to activate Mockito. No Spring context loads at all, which is why these tests run so fast.

@Mock creates a fake BookRepository that you control. @InjectMocks creates a real BookService and wires the mock into it automatically.

The test follows the Arrange-Act-Assert pattern. given() sets up what the mock returns (Arrange). You call the service method (Act). Then assertThat() from AssertJ verifies the result (Assert).

Why use given() from BDDMockito?

BDDMockito's given().willReturn() reads like a specification: "given the repository returns these books, the service should return a list of size 2." It does the same thing as Mockito's when().thenReturn() but reads more naturally alongside test method names.

  • Save BookServiceTest.java.
  • Run the tests by opening the terminal in IntelliJ IDEA and running this command:

macOS

./gradlew test

Windows

.\gradlew test

You should see BUILD SUCCESSFUL in the output. Your new test runs alongside the default test from Step 1, so 2 tests should pass.

Seeing a compilation error?

  • Make sure your file is inside src/test/java/com/example/bookcatalog, not inside src/main/java. Test classes live under the test source set.
  • Check that the package declaration at the top says package com.example.bookcatalog; and matches your folder structure exactly.
  • Verify all the import statements were copied correctly. A missing static import for given or assertThat causes unresolved-symbol errors.

Help me debug my BookServiceTest compilation error.

Write the remaining test methods

Your first test covers the happy path for getAllBooks(). A good test suite also covers lookups, error cases, and writes.

  • In BookServiceTest.java, find the closing brace } of the getAllBooks_returnsListOfBooks method.
  • Add the following two test methods directly below it, still inside the class:
    @Test
    void getBookById_returnsBook_whenBookExists() {
        // Arrange: create a book with a known ID
        Book book = new Book("Clean Code", "Robert Martin", "978-0132350884");
        book.setId(1L);
        given(bookRepository.findById(1L)).willReturn(Optional.of(book));

        // Act: look up the book by ID
        Book result = bookService.getBookById(1L);

        // Assert: the returned book matches
        assertThat(result.getTitle()).isEqualTo("Clean Code");
        assertThat(result.getId()).isEqualTo(1L);
    }

    @Test
    void getBookById_throwsException_whenBookNotFound() {
        // Arrange: the repository returns empty for this ID
        given(bookRepository.findById(99L)).willReturn(Optional.empty());

        // Act + Assert: calling the service should throw
        assertThatThrownBy(() -> bookService.getBookById(99L))
                .isInstanceOf(RuntimeException.class)
                .hasMessageContaining("Book not found");
    }

What do these tests verify?

The first test checks the happy path. When the repository finds a book with ID 1L, the service should return that exact book with the correct title and ID.

The second test checks the error path. When the repository returns Optional.empty(), the service should throw a RuntimeException. assertThatThrownBy() captures the exception and lets you assert on its type and message.

You will not see a change yet. The final test method comes next.

  • Below the getBookById_throwsException_whenBookNotFound method you just added, add this final test method:
    @Test
    void createBook_savesAndReturnsBook() {
        // Arrange: set up the input book and what the repo returns after saving
        Book book = new Book("Clean Code", "Robert Martin", "978-0132350884");
        Book savedBook = new Book("Clean Code", "Robert Martin", "978-0132350884");
        savedBook.setId(1L);
        given(bookRepository.save(any(Book.class))).willReturn(savedBook);

        // Act: call createBook on the service
        Book result = bookService.createBook(book);

        // Assert: the returned book has an ID assigned by the database
        assertThat(result.getId()).isEqualTo(1L);
        assertThat(result.getTitle()).isEqualTo("Clean Code");
    }

What does this test verify?

This test proves that createBook() passes the book to the repository and returns the saved result. any(Book.class) is a Mockito argument matcher that accepts any Book object, so the mock responds regardless of which specific book is passed in.

  • Save BookServiceTest.java.
Run the full test suite
  • Run all the tests by executing this command in the terminal:

macOS

./gradlew test

Windows

.\gradlew test

You should see BUILD SUCCESSFUL with 5 tests passing (4 from BookServiceTest plus the default application test from Step 1).

Notice how fast they run. No Spring context loaded, no database started.

Why are these tests so fast?

Your unit tests use Mockito to simulate the repository layer. Spring never starts, and no H2 database is created. The tests execute in milliseconds because they only exercise Java code in memory.

This speed is the whole point of the testing pyramid's bottom layer. You want hundreds of these fast tests catching bugs before slower integration tests even run.

✔️ Awesome, I've got everything!

Great. Double check you have saved BookServiceTest.java before moving on.

ⓧ I'd like to double check the full code

Here is the complete BookServiceTest.java file. Compare it against yours and fix any differences.

package com.example.bookcatalog;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

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 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.BDDMockito.given;

@ExtendWith(MockitoExtension.class)
class BookServiceTest {

    @Mock
    private BookRepository bookRepository;

    @InjectMocks
    private BookService bookService;

    @Test
    void getAllBooks_returnsListOfBooks() {
        Book book1 = new Book("Clean Code", "Robert Martin", "978-0132350884");
        Book book2 = new Book("Effective Java", "Joshua Bloch", "978-0134685991");
        given(bookRepository.findAll()).willReturn(Arrays.asList(book1, book2));

        List<Book> result = bookService.getAllBooks();

        assertThat(result).hasSize(2);
        assertThat(result.get(0).getTitle()).isEqualTo("Clean Code");
    }

    @Test
    void getBookById_returnsBook_whenBookExists() {
        Book book = new Book("Clean Code", "Robert Martin", "978-0132350884");
        book.setId(1L);
        given(bookRepository.findById(1L)).willReturn(Optional.of(book));

        Book result = bookService.getBookById(1L);

        assertThat(result.getTitle()).isEqualTo("Clean Code");
        assertThat(result.getId()).isEqualTo(1L);
    }

    @Test
    void getBookById_throwsException_whenBookNotFound() {
        given(bookRepository.findById(99L)).willReturn(Optional.empty());

        assertThatThrownBy(() -> bookService.getBookById(99L))
                .isInstanceOf(RuntimeException.class)
                .hasMessageContaining("Book not found");
    }

    @Test
    void createBook_savesAndReturnsBook() {
        Book book = new Book("Clean Code", "Robert Martin", "978-0132350884");
        Book savedBook = new Book("Clean Code", "Robert Martin", "978-0132350884");
        savedBook.setId(1L);
        given(bookRepository.save(any(Book.class))).willReturn(savedBook);

        Book result = bookService.createBook(book);

        assertThat(result.getId()).isEqualTo(1L);
        assertThat(result.getTitle()).isEqualTo("Clean Code");
    }
}

Tests failing or not running?

  • If a test method is not detected, check that it has the @Test annotation from org.junit.jupiter.api.Test (JUnit 5), not org.junit.Test (JUnit 4).
  • If you see an UnnecessaryStubbingException, double check that each given() call is actually used by the service method in that test.
  • Verify all four test methods sit inside the BookServiceTest class braces. A method accidentally placed outside the class will not compile.

Help me debug my failing BookServiceTest tests.

Your service layer is now covered by fast, isolated unit tests. Next up, you will write slice tests that verify your controller handles HTTP requests and returns the right JSON responses.

Write Slice Tests for the Controller Layer

Your unit tests prove that BookService logic works in isolation. But your REST API is more than just a service class. Learners hit HTTP endpoints, send JSON, and expect specific status codes back.

Slice tests sit in the middle of the testing pyramid. They load only the web layer of your Spring Boot application, so they can verify HTTP behavior without the overhead of spinning up the entire app. In this step, you will create BookControllerTest to test your controller endpoints.

In this step, get ready to:
  • Create a slice test class using @WebMvcTest and @MockitoBean.
  • Write test methods that verify HTTP status codes and JSON responses.
  • Run the slice tests and observe how they compare in speed to unit tests.
Create the BookControllerTest class

In the previous step, your unit tests used @ExtendWith(MockitoExtension.class) to run without any Spring context at all. Slice tests take a different approach. They start a partial Spring context that includes only the web layer.

  • In IntelliJ IDEA, right-click the com.example.bookcatalog package inside src/test/java.
  • Select New, then Java Class.
  • Name the class BookControllerTest and press Enter.
  • Replace the entire contents of the new file with the following code:
package com.example.bookcatalog;

import java.util.Arrays;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// Load only the web layer, targeting BookController
@WebMvcTest(BookController.class)
class BookControllerTest {

    // MockMvc simulates HTTP requests without starting a real server
    @Autowired
    private MockMvc mockMvc;

    // Replace the real BookService bean with a Mockito mock
    @MockitoBean
    private BookService bookService;

    @Test
    void getAllBooks_returns200WithBookList() throws Exception {
        // Create two test books and mock the service to return them
        Book book1 = new Book("Clean Code", "Robert Martin", "978-0132350884");
        book1.setId(1L);
        Book book2 = new Book("Effective Java", "Joshua Bloch", "978-0134685991");
        book2.setId(2L);
        given(bookService.getAllBooks()).willReturn(Arrays.asList(book1, book2));

        // Perform a GET request and verify the JSON response
        mockMvc.perform(get("/api/books"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(2))
                .andExpect(jsonPath("$[0].title").value("Clean Code"));
    }
}

What does this code do?

@WebMvcTest(BookController.class) tells Spring to load only the web layer. It scans for @Controller and @ControllerAdvice classes but skips everything else like repositories and database connections.

@MockitoBean replaces the real BookService bean in the Spring context with a Mockito mock. This way, the controller receives a mock service it can call, without needing the real service or its database dependencies.

MockMvc simulates HTTP requests. The test sends a GET to /api/books, then checks that the response status is 200, the JSON array has 2 items, and the first book's title is Clean Code.

Why @MockitoBean instead of @Mock?

In your unit tests, you used @Mock because there was no Spring context. Slice tests start a partial Spring context, and the controller expects a BookService bean to be available for injection.

@MockitoBean creates a mock AND registers it as a bean in the Spring context. This satisfies the controller's dependency on BookService without loading the real service or its repository.

You won't see a change yet. Two more test methods to add before running the suite.

Write the remaining test methods

The first test covers the list endpoint. Now you need tests for retrieving a single book by ID and for creating a new book via POST.

  • In BookControllerTest.java, add the following test method inside the class, below the getAllBooks_returns200WithBookList method:
    @Test
    void getBookById_returns200WithBook() throws Exception {
        // Mock the service to return a specific book when requested by ID
        Book book = new Book("Clean Code", "Robert Martin", "978-0132350884");
        book.setId(1L);
        given(bookService.getBookById(1L)).willReturn(book);

        // Perform a GET request for book ID 1 and verify the JSON fields
        mockMvc.perform(get("/api/books/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title").value("Clean Code"))
                .andExpect(jsonPath("$.author").value("Robert Martin"));
    }

What does this test verify?

This test hits the GET /api/books/1 endpoint and checks that the response JSON contains the correct title and author fields. The jsonPath expressions use the $. prefix to navigate the JSON response body.

You won't see a change yet. One more test method to go.

  • Add the final test method below getBookById_returns200WithBook:
    @Test
    void createBook_returns201WithCreatedBook() throws Exception {
        // Mock the service to return a saved book with an assigned ID
        Book savedBook = new Book("Clean Code", "Robert Martin", "978-0132350884");
        savedBook.setId(1L);
        given(bookService.createBook(any(Book.class))).willReturn(savedBook);

        // Perform a POST request with a JSON body and verify 201 Created
        mockMvc.perform(post("/api/books")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\"Clean Code\",\"author\":\"Robert Martin\",\"isbn\":\"978-0132350884\"}"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.title").value("Clean Code"));
    }

What does this test verify?

This test sends a POST request with a JSON body to /api/books. It sets the Content-Type header to application/json so Spring knows how to deserialize the request body.

The assertion checks for a 201 Created status (not 200 OK), matching what your controller returns via HttpStatus.CREATED. It also verifies that the response includes the auto-generated id field.

  • Save BookControllerTest.java.

Seeing red squiggly lines in the editor?

  • Make sure all the import statements at the top of the file match exactly. The @WebMvcTest import is org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest in Spring Boot 4.1.0.
  • The @MockitoBean import is org.springframework.test.context.bean.override.mockito.MockitoBean. Do not confuse this with the older deprecated @MockBean annotation.
  • Check that each test method has throws Exception in its signature. MockMvc methods throw checked exceptions.

Still stuck? Ask for help.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before running the tests.

ⓧ I'd like to double check the full code

package com.example.bookcatalog;

import java.util.Arrays;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(BookController.class)
class BookControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private BookService bookService;

    @Test
    void getAllBooks_returns200WithBookList() throws Exception {
        Book book1 = new Book("Clean Code", "Robert Martin", "978-0132350884");
        book1.setId(1L);
        Book book2 = new Book("Effective Java", "Joshua Bloch", "978-0134685991");
        book2.setId(2L);
        given(bookService.getAllBooks()).willReturn(Arrays.asList(book1, book2));

        mockMvc.perform(get("/api/books"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(2))
                .andExpect(jsonPath("$[0].title").value("Clean Code"));
    }

    @Test
    void getBookById_returns200WithBook() throws Exception {
        Book book = new Book("Clean Code", "Robert Martin", "978-0132350884");
        book.setId(1L);
        given(bookService.getBookById(1L)).willReturn(book);

        mockMvc.perform(get("/api/books/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title").value("Clean Code"))
                .andExpect(jsonPath("$.author").value("Robert Martin"));
    }

    @Test
    void createBook_returns201WithCreatedBook() throws Exception {
        Book savedBook = new Book("Clean Code", "Robert Martin", "978-0132350884");
        savedBook.setId(1L);
        given(bookService.createBook(any(Book.class))).willReturn(savedBook);

        mockMvc.perform(post("/api/books")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\"Clean Code\",\"author\":\"Robert Martin\",\"isbn\":\"978-0132350884\"}"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.title").value("Clean Code"));
    }
}
Run the slice tests

Time to see your slice tests in action. Running the full test suite will execute both your unit tests from the previous step and the new slice tests.

  • Run the full test suite by running this command in the IntelliJ terminal:

macOS

./gradlew test

Windows

.\gradlew test
  • You should see all 7 tests pass. That is 4 unit tests from BookServiceTest plus 3 slice tests from BookControllerTest.

Notice the speed difference?

Your unit tests run in milliseconds because they skip the Spring context entirely. The slice tests take a bit longer because Spring loads the web layer. You might notice a pause when BookControllerTest runs as Spring initializes.

This is the testing pyramid in action. Slice tests are faster than full integration tests (which load everything), but slower than pure unit tests. The tradeoff is worth it because you are verifying real HTTP behavior.

Tests failing?

  • Check that your BookController.java uses @RequestMapping("/api/books"). The test URLs must match the controller's mapping exactly.
  • Make sure the JSON string in the createBook test uses escaped double quotes. The backslashes before each internal quote are required.
  • Verify that BookControllerTest.java is in the src/test/java/com/example/bookcatalog directory. Test classes in the wrong package won't find the controller.

Help me debug my failing slice tests.

Your slice tests confirm that the controller handles HTTP requests correctly and returns the right status codes and JSON. Next up, you will write integration tests that load the full application stack and hit a real database.

Write Integration Tests

Your unit tests verified business logic in isolation. Your slice tests verified the controller layer with a partial Spring context. But neither test type checked whether all these layers actually work together with a real database.

Integration tests fill that gap. They load the entire Spring Boot application context, wire up a real H2 database, and send HTTP requests through the full stack. In this step, you will write tests that prove your API works end-to-end.

In this step, get ready to:
  • Create the BookIntegrationTest class with full-context annotations.
  • Write test methods that verify end-to-end API behavior with a real database.
  • Run the complete test suite and review the test report.
Create the integration test class

Your unit tests used @ExtendWith(MockitoExtension.class) with no Spring context at all. Your slice tests used @WebMvcTest to load only the web layer. Integration tests use @SpringBootTest to load the full application context, including the real database.

  • In IntelliJ's Project sidebar, right-click the com.example.bookcatalog package inside src/test/java.
  • Select New, then Java Class.
  • Name it BookIntegrationTest and press Enter.
  • Replace the entire contents of the new file with this code:
package com.example.bookcatalog;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// Load the full Spring application context, including the real H2 database
@SpringBootTest
// Auto-configure MockMvc so we can send HTTP requests without a real server
@AutoConfigureMockMvc
// Reset the database after each test so test data doesn't leak between methods
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class BookIntegrationTest {

    // Spring injects a fully configured MockMvc wired to the real application
    @Autowired
    private MockMvc mockMvc;
}

What do these annotations do?

@SpringBootTest loads the entire application context. Every bean your app needs (controller, service, repository, database) is wired up and ready to go. This is the opposite of @WebMvcTest, which only loaded the web layer.

@AutoConfigureMockMvc gives you a MockMvc instance connected to the full application. You can send HTTP requests just like in your slice tests, but now they travel through the real service and repository all the way to the H2 database.

@DirtiesContext resets the application context (and the database) after each test method. This prevents data from one test leaking into another.

Why no @MockitoBean here?

In your slice tests, you used @MockitoBean to replace BookService with a mock. Integration tests use the real service, real repository, and real database. That is the whole point. Nothing is mocked.

You will not see a change yet. The next chunk adds test methods that exercise this class.

Write the test methods

You will write two test methods. The first verifies the API returns an empty list from a fresh database. The second tests a full create-then-retrieve flow.

  • In BookIntegrationTest.java, add this first test method inside the class, below the mockMvc field:
    @Test
    void getAllBooks_returnsEmptyList_whenNoBooksExist() throws Exception {
        // Send a GET request to /api/books against a fresh, empty database
        mockMvc.perform(get("/api/books"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(0));
    }

What does this test do?

This test hits GET /api/books against a real, empty H2 database. It expects a 200 OK response with an empty JSON array. Unlike your slice test, which mocked the service to return whatever you told it, this test reads from the actual database.

You will not see a change yet. The next method completes the test class.

  • Add the second test method below the first one, still inside the class:
    @Test
    void createAndRetrieveBook_fullFlow() throws Exception {
        // First, POST a new book to the API
        mockMvc.perform(post("/api/books")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\"Clean Code\",\"author\":\"Robert Martin\",\"isbn\":\"978-0132350884\"}"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").exists());

        // Then, GET all books and verify the one we just created is there
        mockMvc.perform(get("/api/books"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(1))
                .andExpect(jsonPath("$[0].title").value("Clean Code"));
    }

What does this test do?

This test exercises the full create-then-retrieve flow. It sends a POST request with a JSON book, then sends a GET request to confirm the book was actually persisted in the H2 database.

The first assertion checks that the POST returned 201 Created with an auto-generated id. The second assertion verifies the GET returns exactly one book with the title Clean Code. This is something slice tests could never verify, because they mocked the database layer entirely.

  • Save BookIntegrationTest.java.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

package com.example.bookcatalog;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class BookIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void getAllBooks_returnsEmptyList_whenNoBooksExist() throws Exception {
        mockMvc.perform(get("/api/books"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(0));
    }

    @Test
    void createAndRetrieveBook_fullFlow() throws Exception {
        mockMvc.perform(post("/api/books")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\"Clean Code\",\"author\":\"Robert Martin\",\"isbn\":\"978-0132350884\"}"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").exists());

        mockMvc.perform(get("/api/books"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(1))
                .andExpect(jsonPath("$[0].title").value("Clean Code"));
    }
}
Run the full test suite and review the report

You now have tests at all three layers of the testing pyramid: unit, slice, and integration. Time to run the entire suite together and see the results.

  • Run the full test suite by running this command in the IntelliJ terminal:
.\gradlew test
  • You should see BUILD SUCCESSFUL in the terminal output. All 9 tests across the three test classes should pass.

Notice the speed difference

Pay attention to how long the build takes. Your unit tests (BookServiceTest) run in milliseconds because they never load Spring. Your slice tests (BookControllerTest) are a bit slower because they load the web slice.

Your integration tests (BookIntegrationTest) are the slowest because they boot the entire application context and connect to the H2 database. This is why the testing pyramid recommends having more unit tests than integration tests.

  • Open the test report to see a visual summary. Navigate to build/reports/tests/test/index.html in the Project sidebar.
  • Right-click the file and select Open In, then Browser, then choose your preferred browser.

The report shows all three test classes and their individual test methods. You should see a green bar with 100% success across BookServiceTest, BookControllerTest, and BookIntegrationTest.

Tests failing?

  • Check that the import for @AutoConfigureMockMvc uses the Spring Boot 4.x path: org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc.
  • Make sure the JSON string in your POST request has properly escaped quotes. Each quote inside the string needs a backslash before it.
  • Verify you have @DirtiesContext on the class. Without it, data from one test can leak into the next, causing unexpected counts.

Why are my integration tests failing?

You now have a complete test suite covering all three layers of the testing pyramid. Next up, you will take on the Secret Mission and add exception handling tests to verify your API returns proper error responses when things go wrong.

Secret mission

Test Error Handling

Right now, your API throws a generic RuntimeException when a book isn't found. In production, clients need structured error responses with proper HTTP status codes. Your mission is to add custom exception handling and prove it works with tests at all three layers of the testing pyramid.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with an in-memory H2 database, so there are no ongoing costs. Decide whether to keep your project files to continue experimenting or delete them entirely.

Resources you used:

  • Spring Boot project folder with all source code and test files (local).

Keep everything running

No action needed. Choose this if you want to keep experimenting with the test suite, adding new test cases, or extending the API.

  • Your project folder stays on your machine with all source and test files intact.
  • The H2 database is in-memory, so no data persists between application runs. There is nothing to clean up on the database side.

Pause - I'll come back to this later

Since the project is entirely local, pausing is the same as keeping. There are no running processes or services to stop.

  • If the Spring Boot application is still running in IntelliJ IDEA, click the red square stop button in the toolbar to stop it.
  • Your project files will stay on your machine until you decide to delete them.

Delete - I don't want to use this again

Remove all project files from your machine.

  • If the Spring Boot application is still running in IntelliJ IDEA, stop it first by clicking the red square stop button in the toolbar.
  • Close the project in IntelliJ IDEA.
  • Delete the project folder by running the command for your operating system.

macOS

  • Delete the project folder by running:
rm -rf ~/Desktop/bookcatalog

Windows

  • Delete the project folder by running:
rmdir /s /q %USERPROFILE%\Desktop\bookcatalog

Nice Work!

Nice Work!

Nice work! You built a complete Spring Boot REST API and backed it with a professional-grade test suite covering all three layers of the testing pyramid.

You've learned how to:

  • Built a Spring Boot REST API with a Book entity, repository, service, and controller.
  • Wrote unit tests with JUnit 5 and Mockito to test business logic in isolation without loading Spring context.
  • Wrote slice tests with @WebMvcTest and MockMvc, and integration tests with @SpringBootTest to verify HTTP behavior and full application flow with a real database.
  • Secret Mission: Added exception handling and wrote tests at all three layers to verify error responses.

Ready to quiz yourself?