Testing in Spring Boot: The Test Pyramid and @SpringBootTest
Introduction
This tutorial starts the testing module of the Spring series. We will see which kinds of tests a Spring Boot application needs, arranged as the test pyramid, and what Spring Boot's test starter provides: JUnit 6, AssertJ, Mockito and Spring's own test support. We will then test a small bookstore at three levels: a plain unit test with no Spring at all, an integration test with@SpringBootTest that loads every bean, and an end-to-end test that starts the server on a random port and calls it over HTTP with Spring Framework 7's RestTestClient. Every test below is run for real, and its output is the JUnit report of that run.The test pyramid
Tests differ in how much of the application they start, and therefore in how fast they run and how much they prove. The test pyramid is a rule of thumb for the mix: many small, fast tests at the bottom, fewer larger ones above them -
- Unit tests create objects with
newand test one class. They run in milliseconds, so there can be thousands of them, and a failure points at one class. - Slice tests start one layer of Spring, such as the web layer or the JPA layer, with mocks for the rest. They are the subject of the next tutorialClick to read tutorial: Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and More.
- Integration tests with
@SpringBootTeststart the whole application context. They prove that the beans, the configuration and the auto-configuration work together. - End-to-end tests also start the web server and talk HTTP to it, as a real client would. They prove the most, take the longest, and are the hardest to debug when they fail.
What the test starter provides
A Spring Boot project from Spring Initializr already hasspring-boot-starter-test in test scope. In Spring Boot 4 it brings -
- JUnit 6 (Jupiter):
@Test,@ParameterizedTest, the test engine and lifecycle. - AssertJ: readable assertions,
assertThat(x).isEqualTo(y). - Mockito: mock objects, covered in Mocking beans with @MockitoBeanClick to read tutorial: Mocking Beans in Spring Boot Tests with @MockitoBean.
- Spring Test and Spring Boot Test:
@SpringBootTest, the test context cache, and test clients. - JSONassert, JsonPath and Awaitility, for JSON and asynchronous code.
spring-boot-starter-webmvc-test for web tests, spring-boot-starter-data-jpa-test for JPA, spring-boot-starter-security-test and so on. This tutorial uses the web one, which brings the RestTestClient support.
The application under test
A bookstore with two books. APriceCalculator holds the business rule - 10% off from three copies, at most 500 off - a BookCatalog service uses it, and a controller serves books and price quotes. The files belong in src/main/java; the tests further below in src/test/java, in the same package -
src/main/java/.../Book.java
//Book.java
package com.javabrahman.testing.intro;
public record Book(long id, String title, int price) {
}
src/main/java/.../Quote.java
//Quote.java
package com.javabrahman.testing.intro;
public record Quote(long bookId, int quantity, int total) {
}
src/main/java/.../PriceCalculator.java - pure logic, no Spring needed to test it
//PriceCalculator.java
package com.javabrahman.testing.intro;
import org.springframework.stereotype.Component;
@Component
public class PriceCalculator {
// 10% off from 3 copies, but never more than 500 off in total
public int total(int unitPrice, int quantity) {
if (quantity < 1) {
throw new IllegalArgumentException("quantity must be at least 1, was " + quantity);
}
int full = unitPrice * quantity;
int discount = quantity >= 3 ? Math.min(full / 10, 500) : 0;
return full - discount;
}
}
src/main/java/.../BookCatalog.java - a service using the calculator
//BookCatalog.java
package com.javabrahman.testing.intro;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Service;
@Service
public class BookCatalog {
private final Map<Long, Book> books = Map.of(
1L, new Book(1, "Effective Java", 450),
2L, new Book(2, "Clean Code", 380));
private final PriceCalculator calculator;
public BookCatalog(PriceCalculator calculator) {
this.calculator = calculator;
}
public Optional<Book> find(long id) {
return Optional.ofNullable(books.get(id));
}
public Optional<Quote> quote(long id, int quantity) {
return find(id).map(b -> new Quote(id, quantity, calculator.total(b.price(), quantity)));
}
}
src/main/java/.../BookController.java - the HTTP layer
//BookController.java
package com.javabrahman.testing.intro;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class BookController {
private final BookCatalog catalog;
public BookController(BookCatalog catalog) {
this.catalog = catalog;
}
@GetMapping("/books/{id}")
public ResponseEntity<Book> book(@PathVariable long id) {
return ResponseEntity.of(catalog.find(id));
}
@GetMapping("/books/{id}/quote")
public ResponseEntity<Quote> quote(@PathVariable long id, @RequestParam int quantity) {
return ResponseEntity.of(catalog.quote(id, quantity));
}
}
src/main/java/.../BookstoreApp.java
//BookstoreApp.java
package com.javabrahman.testing.intro;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BookstoreApp {
public static void main(String[] args) {
SpringApplication.run(BookstoreApp.class, args);
}
}
Level 1: a unit test
The price rule is plain Java. Its test creates the calculator withnew - no Spring, no context, nothing to start. @ParameterizedTest runs one test method for each row of @CsvSource, and assertThatThrownBy checks the exception for an invalid quantity -
src/test/java/.../PriceCalculatorTest.java - a unit test
//PriceCalculatorTest.java
package com.javabrahman.testing.intro;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class PriceCalculatorTest {
private final PriceCalculator calculator = new PriceCalculator(); // a plain object: no Spring at all
@Test
void noDiscountBelowThreeCopies() {
assertThat(calculator.total(450, 2)).isEqualTo(900);
}
@ParameterizedTest(name = "{1} x {0} = {2}")
@CsvSource({
"450, 3, 1215", // 10% of 1350 is 135
"450, 20, 8500", // 10% of 9000 would be 900, capped at 500
"100, 1, 100"
})
void totals(int unitPrice, int quantity, int expected) {
assertThat(calculator.total(unitPrice, quantity)).isEqualTo(expected);
}
@Test
void rejectsZeroQuantity() {
assertThatThrownBy(() -> calculator.total(450, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("at least 1");
}
}
Level 2: an integration test with @SpringBootTest
@SpringBootTest starts the application as main() would, but inside the test. It searches upwards from the test's package for the @SpringBootApplication class, builds the whole application context from it, and injects beans into the test's @Autowired fields. Here it checks that the catalog quotes prices with the real calculator, and prints how many beans the context holds -
src/test/java/.../BookCatalogIntegrationTest.java - the real beans, wired by Spring
//BookCatalogIntegrationTest.java
package com.javabrahman.testing.intro;
import static org.assertj.core.api.Assertions.assertThat;
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 BookCatalogIntegrationTest {
@Autowired
BookCatalog catalog;
@Autowired
ApplicationContext context;
@Test
void quotesUseTheRealCalculator() {
assertThat(catalog.quote(1, 3)).hasValue(new Quote(1, 3, 1215));
}
@Test
void unknownBookHasNoQuote() {
assertThat(catalog.quote(99, 1)).isEmpty();
}
@Test
void theWholeApplicationIsLoaded() {
assertThat(context.getBean(BookController.class)).isNotNull();
System.out.println("beans in the test context: " + context.getBeanDefinitionCount());
}
}
Level 3: an end-to-end test over HTTP
By default,@SpringBootTest starts no server: the web layer runs in a mock servlet environment. webEnvironment = RANDOM_PORT starts the embedded Tomcat on a free port instead, so that tests can send real HTTP requests -
@AutoConfigureRestTestClient provides a RestTestClient, new in Spring Framework 7, already pointed at that port. Its fluent API sends a request with exchange() and then checks the status, headers and body. TestRestTemplate, which older tutorials use for this, still exists in Spring Boot 4, moved to the spring-boot-resttestclient module -
src/test/java/.../BookApiEndToEndTest.java - real HTTP against a running server
//BookApiEndToEndTest.java
package com.javabrahman.testing.intro;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.test.web.servlet.client.RestTestClient;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class BookApiEndToEndTest {
@Autowired
RestTestClient client;
@Test
void getsABook() {
client.get().uri("/books/1").exchange()
.expectStatus().isOk()
.expectBody(Book.class).isEqualTo(new Book(1, "Effective Java", 450));
}
@Test
void quotesThreeCopies() {
client.get().uri("/books/1/quote?quantity=3").exchange()
.expectStatus().isOk()
.expectBody().jsonPath("$.total").isEqualTo(1215);
}
@Test
void unknownBookIs404() {
client.get().uri("/books/99").exchange()
.expectStatus().isNotFound();
}
}
Running the tests
In a project you run the tests withmvn test (or gradle test), or from the IDE. The reports below come from JUnit's console launcher, which prints each test as a tree, with a check mark for each passed test, and a summary of the counts. First the unit test -
OUTPUT - the unit test╷
└─ JUnit Jupiter ✔
└─ PriceCalculatorTest ✔
├─ noDiscountBelowThreeCopies() ✔
├─ totals(int, int, int) ✔
│ ├─ "3" x "450" = "1215" ✔
│ ├─ "20" x "450" = "8500" ✔
│ └─ "1" x "100" = "100" ✔
└─ rejectsZeroQuantity() ✔
[ 3 containers found ]
[ 0 containers skipped ]
[ 3 containers started ]
[ 0 containers aborted ]
[ 3 containers successful ]
[ 0 containers failed ]
[ 5 tests found ]
[ 0 tests skipped ]
[ 5 tests started ]
[ 0 tests aborted ]
[ 5 tests successful ]
[ 0 tests failed ]
Explanation of the unit test output
- All five tests passed:
noDiscountBelowThreeCopies(),rejectsZeroQuantity(), and three runs oftotals(int, int, int), one per CSV row. Each run is named by thenamepattern of@ParameterizedTest:"3" x "450" = "1215". - The three rows check the boundaries of the rule: the discount starts at three copies (1,350 less 135), the cap applies at twenty copies (900 would be the 10%, 500 is the maximum), and one copy costs the plain price.
- The "containers" in the summary are the test engine, the class and the parameterized method, which group the tests.
Explanation of the Spring tests output
Then the two Spring tests, run together - OUTPUT - the two Spring testsbeans in the test context: 148
╷
└─ JUnit Jupiter ✔
├─ BookCatalogIntegrationTest ✔
│ ├─ quotesUseTheRealCalculator() ✔
│ ├─ unknownBookHasNoQuote() ✔
│ └─ theWholeApplicationIsLoaded() ✔
└─ BookApiEndToEndTest ✔
├─ quotesThreeCopies() ✔
├─ unknownBookIs404() ✔
└─ getsABook() ✔
[ 3 containers found ]
[ 0 containers skipped ]
[ 3 containers started ]
[ 0 containers aborted ]
[ 3 containers successful ]
[ 0 containers failed ]
[ 6 tests found ]
[ 0 tests skipped ]
[ 6 tests started ]
[ 0 tests aborted ]
[ 6 tests successful ]
[ 0 tests failed ]
- The integration test found the
BookstoreAppclass, built the context, and its three tests passed. The context held 148 beans: our four, and the rest from auto-configuration, as in a running application. This is the strength and the cost of@SpringBootTest: it tests the real configuration, and it builds all of it. - The end-to-end test started Tomcat on a random port and passed its three tests over HTTP: the book as JSON, deserialised and compared with
isEqualTo(new Book(...)); the quote'stotalread with the JsonPath$.total; and a 404 for an unknown book, produced byResponseEntity.of()for an emptyOptional. - JUnit does not promise any particular order of the methods in a class, and the order here differs from the source. Tests must never depend on each other's order.
Choosing the level for a test
- Business rules, calculations, validation logic: unit tests. They are the fastest, and a failure names the broken rule.
- Controllers, JSON mapping, repository queries: slice tests, which start only that layer.
- Configuration, security rules, beans working together: a few
@SpringBootTesttests. - The main paths through the API: a handful of end-to-end tests with
RANDOM_PORT. - Name test classes after what they test, and test methods after the behaviour they check:
rejectsZeroQuantitysays what failed without reading the code.
Summary
In this tutorial we arranged a Spring Boot application's tests as a pyramid: many unit tests, fewer slice and integration tests, and a few end-to-end tests. Spring Boot's test starter brings JUnit 6, AssertJ, Mockito and Spring's test support, and Spring Boot 4 adds a test starter per technology. A plain unit test checked the price rule in milliseconds without Spring, including a parameterized test over the boundaries;@SpringBootTest loaded all 148 beans to check that the catalog uses the real calculator; and RANDOM_PORT with RestTestClient tested the API over real HTTP, including a 404. The next tutorial makes Spring tests faster by loading only one layer: Slice tests with @WebMvcTest, @DataJpaTest and @JsonTestClick to read tutorial: Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and More.Spring Testing Tutorials
Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and MoreClick to read tutorial: Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and More 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
Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and MoreClick to read tutorial: Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and More 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