Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and More

Introduction

This tutorial explains Spring Boot's slice tests: test annotations that start only one layer of the application, instead of all of it. We will test the web layer with @WebMvcTest and MockMvcTester, with the service replaced by a mock; the persistence layer with @DataJpaTest against an embedded database; and the JSON mapping with @JsonTest and JacksonTester. Each test prints how many beans its context holds, compared with a full @SpringBootTest, and a last test shows the error you get when a slice lacks a bean it needs.

Why slices

In the previous tutorialClick to read tutorial: Testing in Spring Boot: The Test Pyramid and @SpringBootTest, @SpringBootTest built the whole application for every test class: every bean, every auto-configuration. That is right for checking that everything works together, and wasteful for testing one controller's JSON or one repository's query. A slice test switches off auto-configuration except for one technology, and registers only the beans of that layer - Spring Boot slice tests compared - @SpringBootTest loads everything, @WebMvcTest the web layer with a mocked service, @DataJpaTest the repositories and an embedded database, @JsonTest only Jackson Fewer beans means faster startup, and a test that cannot accidentally depend on another layer. In Spring Boot 4, each slice lives in its technology's test module: @WebMvcTest in spring-boot-starter-webmvc-test, @DataJpaTest in spring-boot-starter-data-jpa-test, with new package names such as org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest. @JsonTest stays in org.springframework.boot.test.autoconfigure.json.

The application

A bookshop with a JPA entity, a repository with a derived query and a @Query, a service, a controller with a validated POST, and a BookView record that defines the JSON format: the price as price_inr, and the date as 06 Jan 2018 -
src/main/java/.../Book.java - a JPA entity
//Book.java
package com.javabrahman.testing.slices;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.time.LocalDate;

@Entity
public class Book {
  @Id
  @GeneratedValue
  private Long id;
  private String title;
  private String author;
  private int price;
  private LocalDate publishedOn;

  protected Book() {
  }

  public Book(String title, String author, int price, LocalDate publishedOn) {
    this.title = title;
    this.author = author;
    this.price = price;
    this.publishedOn = publishedOn;
  }

  public Long getId() { return id; }
  public String getTitle() { return title; }
  public String getAuthor() { return author; }
  public int getPrice() { return price; }
  public LocalDate getPublishedOn() { return publishedOn; }
}
src/main/java/.../BookRepository.java
//BookRepository.java
package com.javabrahman.testing.slices;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface BookRepository extends JpaRepository<Book, Long> {
  List<Book> findByAuthorOrderByTitle(String author);

  @Query("select coalesce(avg(b.price), 0) from Book b where b.author = :author")
  double averagePrice(String author);
}
src/main/java/.../BookView.java - what the API returns as JSON
//BookView.java
package com.javabrahman.testing.slices;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDate;

public record BookView(
    String title,
    String author,
    @JsonProperty("price_inr") int price,
    @JsonFormat(pattern = "dd MMM yyyy") LocalDate publishedOn) {

  static BookView of(Book b) {
    return new BookView(b.getTitle(), b.getAuthor(), b.getPrice(), b.getPublishedOn());
  }
}
src/main/java/.../NewBook.java - a validated request
//NewBook.java
package com.javabrahman.testing.slices;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;

public record NewBook(@NotBlank String title, @NotBlank String author, @Positive int price) {
}
src/main/java/.../BookService.java
//BookService.java
package com.javabrahman.testing.slices;

import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Service;

@Service
public class BookService {
  private final BookRepository books;

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

  public List<BookView> byAuthor(String author) {
    return books.findByAuthorOrderByTitle(author).stream().map(BookView::of).toList();
  }

  public BookView add(NewBook request) {
    return BookView.of(books.save(new Book(request.title(), request.author(), request.price(), LocalDate.now())));
  }
}
src/main/java/.../BookController.java
//BookController.java
package com.javabrahman.testing.slices;

import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class BookController {
  private final BookService service;

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

  @GetMapping("/books")
  public List<BookView> byAuthor(@RequestParam String author) {
    return service.byAuthor(author);
  }

  @PostMapping("/books")
  @ResponseStatus(HttpStatus.CREATED)
  public BookView add(@Valid @RequestBody NewBook request) {
    return service.add(request);
  }
}
src/main/java/.../ShopApp.java
//ShopApp.java
package com.javabrahman.testing.slices;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ShopApp {

  public static void main(String[] args) {
    SpringApplication.run(ShopApp.class, args);
  }
}
src/main/resources/application.properties
spring.jpa.open-in-view=false

The tests

First a full @SpringBootTest, only to count its beans for comparison -
src/test/java/.../FullContextTest.java - the whole application, for comparison
//FullContextTest.java
package com.javabrahman.testing.slices;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;

@SpringBootTest
class FullContextTest {

  @Autowired
  ApplicationContext context;

  @Test
  void countBeans() {
    System.out.println("@SpringBootTest beans: " + context.getBeanDefinitionCount());
  }
}
@WebMvcTest(BookController.class) loads that one controller with Spring MVC's infrastructure, and provides a MockMvcTester: it sends requests through the real DispatcherServlet, JSON conversion and validation, without a server. The service is not part of the web slice, so @MockitoBean puts a Mockito mock in its place, and given(...).willReturn(...) tells it what to answer -
src/test/java/.../BookControllerTest.java - the web layer only
//BookControllerTest.java
package com.javabrahman.testing.slices;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;

import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.ApplicationContext;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

@WebMvcTest(BookController.class)
class BookControllerTest {

  @Autowired
  MockMvcTester mvc;

  @MockitoBean
  BookService service;          // the controller's dependency, replaced by a Mockito mock

  @Autowired
  ApplicationContext context;

  @Test
  void listsBooksOfAnAuthor() {
    given(service.byAuthor("Joshua Bloch"))
        .willReturn(List.of(new BookView("Effective Java", "Joshua Bloch", 450, LocalDate.of(2018, 1, 6))));

    assertThat(mvc.get().uri("/books").param("author", "Joshua Bloch"))
        .hasStatusOk()
        .bodyJson().extractingPath("$[0].title").isEqualTo("Effective Java");
  }

  @Test
  void rejectsAnInvalidBook() {
    assertThat(mvc.post().uri("/books").contentType(MediaType.APPLICATION_JSON)
        .content("{\"title\": \"\", \"author\": \"Ann\", \"price\": -5}"))
        .hasStatus(400);
  }

  @Test
  void onlyTheWebLayerIsLoaded() {
    System.out.println("@WebMvcTest beans: " + context.getBeanDefinitionCount()
        + ", BookRepository beans: " + context.getBeanNamesForType(BookRepository.class).length);
  }
}
@DataJpaTest loads the entities and repositories, replaces the data source with an embedded database (H2 here), and runs each test in a transaction that is rolled back at the end. TestEntityManager inserts test data directly through JPA, so that the setup does not depend on the repository being tested -
src/test/java/.../BookRepositoryTest.java - the JPA layer only
//BookRepositoryTest.java
package com.javabrahman.testing.slices;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.LocalDate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.ApplicationContext;

@DataJpaTest(showSql = false)       // @DataJpaTest logs every SQL statement unless told otherwise
class BookRepositoryTest {

  @Autowired
  TestEntityManager em;

  @Autowired
  BookRepository books;

  @Autowired
  ApplicationContext context;

  @BeforeEach
  void addBooks() {
    em.persist(new Book("Java Puzzlers", "Joshua Bloch", 300, LocalDate.of(2005, 7, 1)));
    em.persist(new Book("Effective Java", "Joshua Bloch", 450, LocalDate.of(2018, 1, 6)));
    em.persist(new Book("Clean Code", "Robert Martin", 380, LocalDate.of(2008, 8, 1)));
    em.flush();
  }

  @Test
  void findsBooksOfAnAuthorInTitleOrder() {
    assertThat(books.findByAuthorOrderByTitle("Joshua Bloch"))
        .extracting(Book::getTitle).containsExactly("Effective Java", "Java Puzzlers");
  }

  @Test
  void averagesPrices() {
    assertThat(books.averagePrice("Joshua Bloch")).isEqualTo(375.0);
    assertThat(books.averagePrice("Nobody")).isZero();
  }

  @Test
  void eachTestStartsWithTheSameThreeRows() {
    assertThat(books.count()).isEqualTo(3);     // the rows of the other tests were rolled back
    System.out.println("@DataJpaTest beans: " + context.getBeanDefinitionCount()
        + ", BookController beans: " + context.getBeanNamesForType(BookController.class).length);
  }
}
@JsonTest loads Jackson with the application's JSON configuration, and nothing else. JacksonTester writes an object to JSON and reads JSON back, with assertions on JSON paths -
src/test/java/.../BookViewJsonTest.java - the JSON mapping only
//BookViewJsonTest.java
package com.javabrahman.testing.slices;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.context.ApplicationContext;

@JsonTest
class BookViewJsonTest {

  @Autowired
  JacksonTester<BookView> json;

  @Autowired
  ApplicationContext context;

  @Test
  void writesTheApiFormat() throws Exception {
    var written = json.write(new BookView("Effective Java", "Joshua Bloch", 450, LocalDate.of(2018, 1, 6)));
    System.out.println(written.getJson());
    assertThat(written).extractingJsonPathNumberValue("$.price_inr").isEqualTo(450);
    assertThat(written).extractingJsonPathStringValue("$.publishedOn").isEqualTo("06 Jan 2018");
    assertThat(written).doesNotHaveJsonPath("$.price");
  }

  @Test
  void readsTheApiFormat() throws Exception {
    BookView read = json.parseObject("""
        {"title": "Clean Code", "author": "Robert Martin", "price_inr": 380, "publishedOn": "01 Aug 2008"}""");
    assertThat(read.publishedOn()).isEqualTo(LocalDate.of(2008, 8, 1));
    System.out.println("@JsonTest beans: " + context.getBeanDefinitionCount());
  }
}
 OUTPUT - the full context and the three slices
@SpringBootTest beans: 224
@WebMvcTest beans: 127, BookRepository beans: 0
@DataJpaTest beans: 80, BookController beans: 0
@JsonTest beans: 34
{"title":"Effective Java","author":"Joshua Bloch","price_inr":450,"publishedOn":"06 Jan 2018"}
╷
└─ JUnit Jupiter ✔
   ├─ FullContextTest ✔
   │  └─ countBeans() ✔
   ├─ BookControllerTest ✔
   │  ├─ listsBooksOfAnAuthor() ✔
   │  ├─ onlyTheWebLayerIsLoaded() ✔
   │  └─ rejectsAnInvalidBook() ✔
   ├─ BookRepositoryTest ✔
   │  ├─ findsBooksOfAnAuthorInTitleOrder() ✔
   │  ├─ eachTestStartsWithTheSameThreeRows() ✔
   │  └─ averagesPrices() ✔
   └─ BookViewJsonTest ✔
      ├─ readsTheApiFormat() ✔
      └─ writesTheApiFormat() ✔

[         5 containers found      ]
[         0 containers skipped    ]
[         5 containers started    ]
[         0 containers aborted    ]
[         5 containers successful ]
[         0 containers failed     ]
[         9 tests found           ]
[         0 tests skipped         ]
[         9 tests started         ]
[         0 tests aborted         ]
[         9 tests successful      ]
[         0 tests failed          ]

Explanation of the output

  • The bean counts, printed by the tests: 224 for @SpringBootTest, 127 for @WebMvcTest, 80 for @DataJpaTest and 34 for @JsonTest. The web slice holds no BookRepository, and the JPA slice no BookController: each slice really contains one layer.
  • The web slice: the GET returned the mocked book as JSON, checked with bodyJson().extractingPath("$[0].title"). The POST with a blank title and a negative price got 400 from @Valid, without the mocked service ever being called. Spring MVC also logs the rejected request at WARN level, as "Resolved [MethodArgumentNotValidException ...]"; that line is left out of the output above.
  • The JPA slice: the derived query returned Bloch's books in title order, the @Query averaged their prices to 375.0, and coalesce turned "no books" into 0. Each of the three tests inserted three books in @BeforeEach, yet count() was 3, not 6 or 9: the rows of the earlier tests had been rolled back. @DataJpaTest also prints every SQL statement by default; showSql = false switched that off here.
  • The JSON slice: the written JSON is {"title":"Effective Java","author":"Joshua Bloch","price_inr":450,"publishedOn":"06 Jan 2018"}, with the renamed field and the formatted date, and no price field. Reading the same format back produced the right LocalDate. This catches a broken API format - a renamed field, a changed date format - in a test that takes milliseconds once Jackson is loaded.

When a slice is missing a bean

A slice loads only its layer, so anything else a loaded bean needs must be supplied by the test. Here is the controller test without its @MockitoBean -
src/test/java/.../ControllerWithoutMockTest.java - a slice missing a bean
//ControllerWithoutMockTest.java
package com.javabrahman.testing.slices;

import org.junit.jupiter.api.Test;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;

@WebMvcTest(BookController.class)          // no @MockitoBean for BookService this time
class ControllerWithoutMockTest {

  @Test
  void anything() {
  }
}
 OUTPUT - the failing slice (the lines about the failure, long lines shortened)
   └─ ControllerWithoutMockTest ✔
      └─ anything() ✘ Failed to load ApplicationContext ...
     Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.javabrahman.testing.slices.BookService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
[         0 tests successful      ]
[         1 tests failed          ]
The context could not start: "Failed to load ApplicationContext", caused by NoSuchBeanDefinitionException: No qualifying bean of type 'com.javabrahman.testing.slices.BookService' available. The controller needs a BookService, and services are not part of the web slice. This error in a slice test almost always means "mock this dependency": add a @MockitoBean for it, or, for a small helper bean that the controller should really use, add it with @Import(TheHelper.class).

Other slices, and slice tips

  • More slices exist for other technologies, each in its test starter: @WebFluxTest, @DataJdbcTest, @JdbcTest, @DataMongoTest, @DataRedisTest, @RestClientTest for code that calls other services, and @GraphQlTest.
  • Name the controller: @WebMvcTest(BookController.class) loads one controller. Without a class, it loads all of them, and every controller's dependencies then need mocks.
  • A different database: @DataJpaTest uses an embedded database by default. To test against the real database type, use @AutoConfigureTestDatabase(replace = Replace.NONE) with Testcontainers.
  • Configuration on the main class, such as @EnableScheduling on @SpringBootApplication, applies to every slice, since every slice starts from that class. Keep such annotations in separate @Configuration classes.
  • Slices complement @SpringBootTest: most web and persistence behaviour is tested in slices, and a few full tests check that the layers fit together.

Summary

In this tutorial we tested one layer at a time. @WebMvcTest loaded the web layer - 127 beans instead of 224 - and tested a GET and a validation failure through MockMvcTester, with the service replaced by a @MockitoBean. @DataJpaTest loaded 80 beans with an embedded H2 database, and tested a derived query and a @Query, with every test's data rolled back. @JsonTest loaded 34 beans and checked the API's JSON format in both directions with JacksonTester. A slice missing a dependency failed with NoSuchBeanDefinitionException, the sign that the test must supply a mock. The next tutorial looks closely at those mocks: Mocking beans with @MockitoBeanClick to read tutorial: Mocking Beans in Spring Boot Tests with @MockitoBean.