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 -
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());
}
}
@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@DataJpaTestand 34 for@JsonTest. The web slice holds noBookRepository, and the JPA slice noBookController: 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
@Queryaveraged their prices to 375.0, andcoalesceturned "no books" into 0. Each of the three tests inserted three books in@BeforeEach, yetcount()was 3, not 6 or 9: the rows of the earlier tests had been rolled back.@DataJpaTestalso prints every SQL statement by default;showSql = falseswitched 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 nopricefield. Reading the same format back produced the rightLocalDate. 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() {
}
}
└─ 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 ]
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,@RestClientTestfor 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:
@DataJpaTestuses 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
@EnableSchedulingon@SpringBootApplication, applies to every slice, since every slice starts from that class. Keep such annotations in separate@Configurationclasses. - 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.Spring Testing Tutorials
Testing in Spring Boot: The Test Pyramid and @SpringBootTestClick to read tutorial: Testing in Spring Boot: The Test Pyramid and @SpringBootTest Mocking Beans in Spring Boot Tests with @MockitoBeanClick to read tutorial: Mocking Beans in Spring Boot Tests with @MockitoBean Testing REST APIs with MockMvc and MockMvcTesterClick to read tutorial: Testing REST APIs with MockMvc and MockMvcTester Testing Spring Security: @WithMockUser, CSRF and JWTClick to read tutorial: Testing Spring Security: @WithMockUser, CSRF and JWT Contract Testing with Spring Cloud ContractClick to read tutorial: Contract Testing with Spring Cloud Contract Spring Boot Testing Best Practices and Faster Test SuitesClick to read tutorial: Spring Boot Testing Best Practices and Faster Test Suites
Testing in Spring Boot: The Test Pyramid and @SpringBootTestClick to read tutorial: Testing in Spring Boot: The Test Pyramid and @SpringBootTest Mocking Beans in Spring Boot Tests with @MockitoBeanClick to read tutorial: Mocking Beans in Spring Boot Tests with @MockitoBean Testing REST APIs with MockMvc and MockMvcTesterClick to read tutorial: Testing REST APIs with MockMvc and MockMvcTester Testing Spring Security: @WithMockUser, CSRF and JWTClick to read tutorial: Testing Spring Security: @WithMockUser, CSRF and JWT Contract Testing with Spring Cloud ContractClick to read tutorial: Contract Testing with Spring Cloud Contract Spring Boot Testing Best Practices and Faster Test SuitesClick to read tutorial: Spring Boot Testing Best Practices and Faster Test Suites