Article 37 tested ProductService as a plain Java object: JUnit, AssertJ and a Mockito mock of the repository, with no Spring in sight. That proves the rules inside the service, but not the parts Spring wires together: whether POST /api/products binds and validates its body, whether the security chain answers 401 before the controller runs, whether the advice turns an exception into the right ProblemDetail, whether a derived query sends the SQL you expect. Those need an application context, and Spring Boot lets you choose how much of one to start: a slice with only the web layer (@WebMvcTest), a slice with only JPA (@DataJpaTest), or the whole application (@SpringBootTest).
The examples use Spring Boot 4.1.1 and Java 21, on an Initializr project with the web, validation, security, OAuth2 resource server, Spring Data JPA and H2 dependencies. Timings are indicative, and each is labelled with the one-minute load average at the time it was taken.
![]()
The sections go from the smallest context to the largest, then look at what decides how fast a suite of them runs: the test context cache.
The catalogue under test
The tests need something realistic to test, so the project is a compact version of the catalogue from Chapters 3 to 5: product endpoints with DTO records, article 20's GlobalExceptionHandler, article 27's repository queries, and the JWT resource server of articles 35 and 36.
src/main/java/com/example/demo
├── DemoApplication.java
├── common
│ ├── GlobalExceptionHandler.java
│ ├── ProblemDetailSecurityHandler.java
│ └── SecurityConfig.java
├── product
│ ├── CreateProductRequest.java
│ ├── DuplicateSkuException.java
│ ├── Product.java
│ ├── ProductController.java
│ ├── ProductNotFoundException.java
│ ├── ProductRepository.java
│ ├── ProductResponse.java
│ └── ProductService.java
└── user
├── AppUser.java
├── AppUserRepository.java
├── AuthController.java
├── JpaUserDetailsService.java
├── LoginRequest.java
├── Role.java
├── TokenResponse.java
├── TokenService.java
└── UserSeeder.javaThe entity maps the products table with an IDENTITY key, a unique sku and a numeric(10,2) price:
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 40, unique = true)
private String sku;
@Column(nullable = false, length = 120)
private String name;
@Column(nullable = false, length = 60)
private String category;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
protected Product() {
}
public Product(String sku, String name, String category, BigDecimal price) {
this.sku = sku;
this.name = name;
this.category = category;
this.price = price;
}
// getters, plus setName and setPrice
}The repository carries one query of each kind article 27 taught:
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findBySku(String sku);
boolean existsBySku(String sku);
List<Product> findByCategoryOrderByPriceAsc(String category);
@Query("select p from Product p where lower(p.name) like lower(concat('%', :text, '%')) order by p.name")
List<Product> search(String text);
@Modifying(clearAutomatically = true)
@Query("update Product p set p.price = p.price * :factor where p.category = :category")
int changePrices(String category, BigDecimal factor);
}The service and the controller:
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
public List<Product> findAll() {
return repository.findAll(Sort.by("id"));
}
public Product findById(long id) {
return repository.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
}
@Transactional
public Product create(Product product) {
if (repository.existsBySku(product.getSku())) {
throw new DuplicateSkuException(product.getSku());
}
return repository.save(product);
}
}@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping
public List<ProductResponse> findAll() {
return service.findAll().stream().map(ProductResponse::from).toList();
}
@GetMapping("/{id}")
public ProductResponse findById(@PathVariable long id) {
return ProductResponse.from(service.findById(id));
}
@PostMapping
public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
Product product = service.create(request.toProduct());
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(product.getId())
.toUri();
return ResponseEntity.created(location).body(ProductResponse.from(product));
}
}CreateProductRequest is a record with @NotBlank on sku, name and category and @NotNull @Positive on price; ProductResponse carries id, sku, name, category and price. GlobalExceptionHandler extends ResponseEntityExceptionHandler and answers 404 for ProductNotFoundException, 409 for DuplicateSkuException, 422 with a sorted errors list for an invalid body and 500 from a catch-all, and it rethrows AccessDeniedException as article 36 showed. SecurityConfig holds article 36's API chain and the beans articles 34 and 35 added:
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
.requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
.authenticationEntryPoint(problemHandler))
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(problemHandler)
.accessDeniedHandler(problemHandler))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) {
return configuration.getAuthenticationManager();
}
@Bean
JwtEncoder jwtEncoder(
@Value("${spring.security.oauth2.resourceserver.jwt.public-key-location}") RSAPublicKey publicKey,
@Value("${app.jwt.private-key-location}") RSAPrivateKey privateKey) {
return NimbusJwtEncoder.withKeyPair(publicKey, privateKey).build();
}
}POST /api/auth/login authenticates against the users table through JpaUserDetailsService and returns {"accessToken":…,"tokenType":"Bearer","expiresIn":900} from TokenService; UserSeeder, a CommandLineRunner, inserts alice with role USER and admin with role ADMIN. The configuration:
spring.application.name=demo
spring.datasource.url=jdbc:h2:mem:catalog
spring.jpa.open-in-view=false
spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:certs/public.pem
spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles
spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_
app.jwt.private-key-location=classpath:certs/private.pemTest starters and imports in Spring Boot 4
The test dependencies Spring Initializr generates
Article 37 went through this build file: one test starter per main starter, the JUnit Platform launcher, useJUnitPlatform(), and spring-boot-starter-test bringing JUnit 6.0.3, AssertJ and Mockito. This project adds the security and resource-server starters, so it gets two more test starters:
dependencies {
implementation 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test'
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}What matters for this article is which Boot module, and therefore which annotations, each test starter brings. ./gradlew dependencies --configuration testRuntimeClasspath shows it:
| Test starter | Brings | Gives you |
|---|---|---|
spring-boot-starter-test (transitive, from every test starter) | spring-boot-test, spring-boot-test-autoconfigure, spring-test 7.0.9, JsonPath 2.10.0, JSONassert 1.5.3, plus article 37's JUnit, AssertJ and Mockito | @SpringBootTest, @MockitoBean, MockMvcTester, RestTestClient |
spring-boot-starter-webmvc-test | spring-boot-webmvc-test, spring-boot-resttestclient | @WebMvcTest, @AutoConfigureMockMvc, @AutoConfigureRestTestClient |
spring-boot-starter-data-jpa-test | spring-boot-data-jpa-test, spring-boot-jpa-test, spring-boot-jdbc-test | @DataJpaTest, TestEntityManager, @AutoConfigureTestDatabase |
spring-boot-starter-security-test | spring-boot-security-test, spring-security-test 7.1.1 | @WithMockUser, jwt(), security for MockMvc |
The JVM warning A Java agent has been loaded dynamically, caused by Mockito and removed in article 37 with a -javaagent, also appears in these runs; the same fix applies.
Boot 3 imports that no longer compile
Test annotations moved into those modules and their packages changed with them. A web test and a repository test written with Boot 3 imports:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;./gradlew compileTestJava> Task :compileTestJava FAILED
src/test/java/com/example/demo/product/ProductControllerTest.java:5: error: package org.springframework.boot.test.autoconfigure.web.servlet does not exist
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
^
src/test/java/com/example/demo/product/ProductControllerTest.java:6: error: package org.springframework.boot.test.mock.mockito does not exist
import org.springframework.boot.test.mock.mockito.MockBean;
^
src/test/java/com/example/demo/product/ProductRepositoryTest.java:5: error: package org.springframework.boot.test.autoconfigure.orm.jpa does not exist
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
^
src/test/java/com/example/demo/product/ProductRepositoryTest.java:7: error: cannot find symbol
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
^
symbol: class AutoConfigureTestDatabase
location: package org.springframework.boot.test.autoconfigure.jdbcThe build reported 10 errors; the other six are cannot find symbol on the annotations and fields that used those imports, and the paths are shortened to the project root. The jdbc import fails differently because org.springframework.boot.test.autoconfigure.jdbc still exists in 4.1.1 for other classes, just without AutoConfigureTestDatabase. The new locations, checked by listing the 4.1.1 jars:
| Type | Spring Boot 3 import | Spring Boot 4.1.1 import | Jar |
|---|---|---|---|
@WebMvcTest | org.springframework.boot.test.autoconfigure.web.servlet | org.springframework.boot.webmvc.test.autoconfigure | spring-boot-webmvc-test |
@AutoConfigureMockMvc | org.springframework.boot.test.autoconfigure.web.servlet | org.springframework.boot.webmvc.test.autoconfigure | spring-boot-webmvc-test |
@DataJpaTest | org.springframework.boot.test.autoconfigure.orm.jpa | org.springframework.boot.data.jpa.test.autoconfigure | spring-boot-data-jpa-test |
@AutoConfigureTestDatabase | org.springframework.boot.test.autoconfigure.jdbc | org.springframework.boot.jdbc.test.autoconfigure | spring-boot-jdbc-test |
TestEntityManager | org.springframework.boot.test.autoconfigure.orm.jpa | org.springframework.boot.jpa.test.autoconfigure | spring-boot-jpa-test |
@MockBean | org.springframework.boot.test.mock.mockito.MockBean | @MockitoBean in org.springframework.test.context.bean.override.mockito | spring-test |
TestRestTemplate | org.springframework.boot.test.web.client | org.springframework.boot.resttestclient | spring-boot-resttestclient |
@SpringBootTest | org.springframework.boot.test.context | unchanged | spring-boot-test |
@MockBean is not a moved class: no 4.1.1 jar contains it. Its replacement, @MockitoBean, belongs to Spring Framework's bean override support in spring-test, next to @MockitoSpyBean, which replaces @SpyBean.
How much of the application does each test load?
A test can start anywhere on a spectrum. At one end is article 37's unit test: the service built with new, its repositories replaced by Mockito mocks, no context at all, three ProductServiceTest tests in 5 ms. At the other end is @SpringBootTest, which starts the application as main would. In between, a slice starts one layer: @WebMvcTest switches off auto-configuration except the web, security and JSON parts and scans only web components, @DataJpaTest does the same for JPA. Fewer beans usually means a faster start, but not always, and the only way to know for a given project is to measure.
A TestExecutionListener prints, for each test class, how long it took to get its context and how many bean definitions the context holds:
package com.example.demo;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
import org.springframework.context.ApplicationContext;
import org.springframework.core.Ordered;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.TestExecutionListener;
public class ContextStatsListener implements TestExecutionListener, Ordered {
private static final Set<ApplicationContext> seen = Collections.newSetFromMap(new IdentityHashMap<>());
@Override
public void beforeTestClass(TestContext testContext) {
long start = System.nanoTime();
ApplicationContext context = testContext.getApplicationContext();
long millis = (System.nanoTime() - start) / 1_000_000;
boolean reused = !seen.add(context);
System.out.printf("[context] %s: %s, %d bean definitions%n",
testContext.getTestClass().getSimpleName(),
reused ? "reused from cache" : "loaded in " + millis + " ms",
context.getBeanDefinitionCount());
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}org.springframework.test.context.TestExecutionListener=com.example.demo.ContextStatsListenerEach test class of the following sections was run on its own, in a fresh JVM, three times with ./gradlew test --rerun --tests <class>. The best run of each, next to Spring Boot's own Started … in line from the same run:
| Test class | Annotation | Bean definitions | Started … in (best of 3) | Listener | Load average |
|---|---|---|---|---|---|
ProductControllerTest | @WebMvcTest(ProductController.class) + security @Import | 175 | 0.932 s | 943 ms | 6.96 |
ProductRepositoryTest | @DataJpaTest | 83 | 1.335 s | 1349 ms | 7.80 |
ProductApiMockEnvironmentTest | @SpringBootTest + @AutoConfigureMockMvc | 289 | 1.707 s | 1803 ms | 6.87 |
ProductApiRandomPortTest | @SpringBootTest(webEnvironment = RANDOM_PORT) + @AutoConfigureRestTestClient | 287 | 1.931 s | 2025 ms | 6.96 |
The JPA slice has the fewest beans and still starts slower than the web slice: most of its time is Hibernate building the metamodel and creating the schema, not bean count. These first-context numbers include JVM warm-up; inside a running suite the same contexts loaded in a fraction of that, as the context cache section shows.

@WebMvcTest with MockMvcTester
What the web slice loads
@WebMvcTest(ProductController.class) starts Spring MVC without a server. It imports only the auto-configurations listed for web tests; the failing context below printed them in its cache key: WebMvcAutoConfiguration, ErrorMvcAutoConfiguration, HttpMessageConvertersAutoConfiguration, JacksonAutoConfiguration, ValidationAutoConfiguration, MockMvcAutoConfiguration, the Spring Security and resource-server auto-configurations, and a few more, 19 in all, with no DataSource, no JPA and no Tomcat. Component scanning is filtered to web types: @Controller classes (only ProductController, because it is named), @ControllerAdvice, WebMvcConfigurer, Filter, converters, HandlerInterceptor and Jackson modules. @Service, @Repository, @Component and @Configuration classes are left out.
The first test only asks for a product:
package com.example.demo.product;
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.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
MockMvcTester mvc;
@Test
void returnsProductById() {
assertThat(mvc.get().uri("/api/products/1")).hasStatusOk();
}
}./gradlew testWith Gradle's default test logging, the console shows only the exception chain:
ProductControllerTest > returnsProductById() FAILED
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:195
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException at ConstructorResolver.java:804
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException at DefaultListableBeanFactory.java:2304The test report holds Spring Boot's failure analysis:
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.example.demo.product.ProductController required a bean of type 'com.example.demo.product.ProductService' that could not be found.
Action:
Consider defining a bean of type 'com.example.demo.product.ProductService' in your configuration.The controller was created, its service was not: the slice left ProductService out, and with it the repository and the database it would need.
@MockitoBean supplies the service
@MockitoBean registers a Mockito mock in the context under the field's type, so the controller gets it through its constructor and the test stubs it:
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
MockMvcTester mvc;
@MockitoBean
ProductService productService; With it the context started, and a debug test listed its application beans: demoApplication, productController, globalExceptionHandler and the mock, registered as com.example.demo.product.ProductService#0, next to jacksonJsonMapper, defaultValidator, mvcValidator, mockMvc, mockMvcTester and jwtDecoderByPublicKeyValue. There was no productRepository, entityManagerFactory, dataSource, authController, tokenService or userSeeder, and no securityConfig either, which matters in the security section. A mock replaces the bean for this context only, and Spring resets it after each test method, so a stub from one test does not leak into the next.

GET: status and JSON assertions
MockMvcTester is Spring Framework's AssertJ entry point to MockMvc, and Boot registers a bean of it in every context with MockMvc when AssertJ is on the classpath. mvc.get().uri(...) builds the request, and assertThat(...) performs it and returns an assertion object for the result. The complete test class, security imports included (the security section explains them):
package com.example.demo.product;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import java.math.BigDecimal;
import java.util.List;
import com.example.demo.common.ProblemDetailSecurityHandler;
import com.example.demo.common.SecurityConfig;
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.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
@WebMvcTest(ProductController.class)
@Import({SecurityConfig.class, ProblemDetailSecurityHandler.class})
class ProductControllerTest {
private static final String NEW_PRODUCT = """
{"sku":"HB-001","name":"USB-C hub","category":"hubs","price":35.00}
""";
@Autowired
MockMvcTester mvc;
@MockitoBean
ProductService productService;
@Test
void getReturnsTheProductAsJson() {
given(productService.findById(1L)).willReturn(product(1L, "KB-001", "Mechanical keyboard", "89.90"));
assertThat(mvc.get().uri("/api/products/1"))
.hasStatusOk()
.hasContentType(MediaType.APPLICATION_JSON)
.bodyJson()
.isLenientlyEqualTo("""
{"id":1,"sku":"KB-001","name":"Mechanical keyboard","price":89.90}
""");
}
// the tests of the next sections go here
static Product product(long id, String sku, String name, String price) {
return withId(new Product(sku, name, "keyboards", new BigDecimal(price)), id);
}
static Product withId(Product product, long id) {
ReflectionTestUtils.setField(product, "id", id);
return product;
}
}hasStatusOk()andhasContentType(...)check the response itself.bodyJson()switches to JSON assertions on the body.isLenientlyEqualTocompares with JSONassert in lenient mode: the fields listed must match,categorymay be there without being mentioned, and array order is not enforced.isStrictlyEqualToenforces both.ReflectionTestUtils.setFieldgives the entity an id without a setter, which only JPA normally assigns.
JSON paths reach into a list:
@Test
void getListExposesJsonPaths() {
given(productService.findAll()).willReturn(List.of(
product(1L, "KB-001", "Mechanical keyboard", "89.90"),
product(2L, "MS-001", "Wireless mouse", "24.50")));
assertThat(mvc.get().uri("/api/products"))
.hasStatusOk()
.bodyJson()
.hasPathSatisfying("$.length()", length -> assertThat(length).asNumber().isEqualTo(2))
.extractingPath("$[*].sku").asArray().containsExactly("KB-001", "MS-001");
}extractingPath evaluates a JsonPath expression and returns an assertion on the value, which asArray(), asString(), asNumber(), asBoolean() and asMap() turn into the matching AssertJ type. hasPathSatisfying keeps the chain on the whole document. The body can also be read back into the response record with the application's own JsonMapper:
@Test
void getConvertsTheBodyToTheResponseRecord() {
given(productService.findById(1L)).willReturn(product(1L, "KB-001", "Mechanical keyboard", "89.90"));
assertThat(mvc.get().uri("/api/products/1"))
.bodyJson()
.convertTo(ProductResponse.class)
.satisfies(response -> {
assertThat(response.sku()).isEqualTo("KB-001");
assertThat(response.price()).isEqualByComparingTo("89.90");
});
}POST with a body: 201 and Location
A write needs the ADMIN role the chain requires; @WithMockUser provides it here, and the security section compares it with a JWT. any(Product.class) matches the entity the controller builds from the JSON, and the stub hands it back with an id:
@Test
@WithMockUser(roles = "ADMIN")
void postCreatesTheProduct() {
given(productService.create(any(Product.class)))
.willAnswer(invocation -> withId(invocation.getArgument(0), 42L));
assertThat(mvc.post().uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(NEW_PRODUCT))
.hasStatus(HttpStatus.CREATED)
.hasHeader("Location", "http://localhost/api/products/42")
.bodyJson()
.extractingPath("$.id").asNumber().isEqualTo(42);
}The request never touched a network. http://localhost in the Location header is MockHttpServletRequest's default server name and port 80, which ServletUriComponentsBuilder.fromCurrentRequest() read as it would read a real request.
ProblemDetail responses: 404, 409, 422 and 500
The advice is part of the slice, so error responses are tested exactly as a client receives them. The service stub throws; the controller does not catch; GlobalExceptionHandler writes the body:
@Test
void unknownIdIsA404ProblemDetail() {
given(productService.findById(99L)).willThrow(new ProductNotFoundException(99L));
assertThat(mvc.get().uri("/api/products/99"))
.hasStatus(HttpStatus.NOT_FOUND)
.hasContentType(MediaType.APPLICATION_PROBLEM_JSON)
.bodyJson()
.isLenientlyEqualTo("""
{"title":"Product not found","status":404,"detail":"Product 99 not found","productId":99}
""");
}
@Test
@WithMockUser(roles = "ADMIN")
void duplicateSkuIsA409ProblemDetail() {
given(productService.create(any(Product.class))).willThrow(new DuplicateSkuException("HB-001"));
assertThat(mvc.post().uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(NEW_PRODUCT))
.hasStatus(HttpStatus.CONFLICT)
.bodyJson()
.isLenientlyEqualTo("""
{"title":"Duplicate SKU","status":409,"sku":"HB-001"}
""");
}The 422 needs no stub at all, because validation runs before the controller method. The last line proves the service was never called:
@Test
@WithMockUser(roles = "ADMIN")
void invalidBodyIsA422WithFieldErrors() {
assertThat(mvc.post().uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"sku":"","name":"USB-C hub","category":"hubs","price":-5}
"""))
.hasStatus(HttpStatus.UNPROCESSABLE_CONTENT)
.bodyJson()
.isLenientlyEqualTo("""
{"status":422,"errors":[
{"field":"price","message":"must be greater than 0"},
{"field":"sku","message":"must not be blank"}]}
""");
then(productService).shouldHaveNoInteractions();
}An unexpected exception reaches the catch-all:
@Test
void unexpectedErrorIsA500ProblemDetail() {
given(productService.findById(1L)).willThrow(new IllegalStateException("Database is down"));
assertThat(mvc.get().uri("/api/products/1"))
.hasStatus(HttpStatus.INTERNAL_SERVER_ERROR)
.bodyJson()
.extractingPath("$.detail").asString().isEqualTo("An unexpected error occurred.");
}It passed, and the test log carried the advice's own line, ERROR … c.e.demo.common.GlobalExceptionHandler : Unhandled exception on GET /api/products/1, with the stack trace, just as in production.
Classic MockMvc: perform and andExpect
The same context also has a MockMvc bean, the API that tutorials written before Spring Framework 6.2 use. The GET test in that style:
package com.example.demo.product;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.example.demo.common.ProblemDetailSecurityHandler;
import com.example.demo.common.SecurityConfig;
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.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(ProductController.class)
@Import({SecurityConfig.class, ProblemDetailSecurityHandler.class})
class ProductControllerMockMvcTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
ProductService productService;
@Test
void getReturnsTheProductAsJson() throws Exception {
given(productService.findById(1L))
.willReturn(ProductControllerTest.product(1L, "KB-001", "Mechanical keyboard", "89.90"));
mockMvc.perform(get("/api/products/1"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.sku").value("KB-001"))
.andExpect(jsonPath("$.price").value(89.90));
}
}Both pass. Four deliberately wrong assertions show how each reports a failure. Expecting 201 for the GET, MockMvcTester said:
org.opentest4j.AssertionFailedError: [HTTP status code]
expected: 201
but was: 200and MockMvc:
java.lang.AssertionError: Status expected:<201> but was:<200>Expecting KB-002 at $.sku, MockMvcTester said:
org.opentest4j.AssertionFailedError:
expected: "KB-002"
but was: "KB-001"and MockMvc:
java.lang.AssertionError: JSON path "$.sku" expected:<KB-002> but was:<KB-001>The messages are equally clear, and for a JSON path the classic one even names the path. The reasons the series uses MockMvcTester are elsewhere:
- One import style. No static
get,status,contentorjsonPathimports from three builder and matcher classes; the request starts frommvcand the checks are ordinary AssertJ. - No
throws Exception.performdeclares it;MockMvcTesterdoes not. - Typed values.
extractingPath(...).asNumber(),asArray().containsExactly(...)andconvertTo(ProductResponse.class)give the whole AssertJ API to a value, wherejsonPath(...).value(...)checks one expected value. - Whole-document comparison.
isLenientlyEqualTochecks a JSON object in one statement.
Existing MockMvc tests keep working unchanged, and both styles can live in the same context.
Security in @WebMvcTest
Without @Import: Spring Boot's default resource server chain
The first @WebMvcTest above had no security configuration of its own, yet it did have security. SecurityConfig is a @Configuration class, which the slice's filter leaves out, but the slice includes Spring Security's and the resource server's auto-configurations. With public-key-location set, Boot built its own default chain, and a debug test printed it:
DefaultSecurityFilterChain defined as 'jwtSecurityFilterChain' in [class path resource [org/springframework/boot/security/oauth2/server/resource/autoconfigure/web/OAuth2ResourceServerWebSecurityAutoConfiguration.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, OAuth2ProtectedResourceMetadata, BearerTokenAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization]That chain requires authentication for every request and keeps CSRF protection on. With @MockitoBean ProductService and no @Import, requests without a token got:
| Request | Status | WWW-Authenticate | Body |
|---|---|---|---|
GET /api/products/1 | 401 | Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource" | empty |
POST /api/products | 403 | none | empty |
POST /api/products with spring-security-test's csrf() | 401 | Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource" | empty |
None of those is the application's answer. The public GET should be 200. The POST is refused by CsrfFilter before authentication is even considered, which the third row confirms: with a CSRF token the same request reaches authentication and gets 401. And no body is a ProblemDetail, because the application's entry point is not there. A web test that passes against this chain is testing Spring Boot's defaults, not your rules.
@Import the SecurityConfig and what it needs
@Import adds configuration classes to the slice. Importing SecurityConfig alone:
Description:
Parameter 1 of method apiSecurityFilterChain in com.example.demo.common.SecurityConfig required a bean of type 'com.example.demo.common.ProblemDetailSecurityHandler' that could not be found.ProblemDetailSecurityHandler is a @Component, so the slice skipped it too. Both go in:
@WebMvcTest(ProductController.class)
@Import({SecurityConfig.class, ProblemDetailSecurityHandler.class})
class ProductControllerTest {Boot's jwtSecurityFilterChain backs off as soon as the application defines a SecurityFilterChain, so the slice now runs apiSecurityFilterChain: public reads, ADMIN writes, CSRF disabled, 401 and 403 as ProblemDetail. The handler's JsonMapper comes from JacksonAutoConfiguration, which the slice includes, and the JwtDecoder from the resource server auto-configuration.
@WithMockUser versus jwt(): 401, 403 and 201
Three tests pin the rules down from the outside:
@Test
void postWithoutATokenIs401() {
assertThat(mvc.post().uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(NEW_PRODUCT))
.hasStatus(HttpStatus.UNAUTHORIZED)
.hasHeader("WWW-Authenticate", "Bearer realm=\"catalogue\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")
.bodyJson()
.extractingPath("$.detail").asString().isEqualTo("Valid credentials are required to access this resource.");
}
@Test
void postWithAUserJwtIs403() {
assertThat(mvc.post().uri("/api/products")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER")))
.contentType(MediaType.APPLICATION_JSON)
.content(NEW_PRODUCT))
.hasStatus(HttpStatus.FORBIDDEN)
.bodyJson()
.extractingPath("$.title").asString().isEqualTo("Forbidden");
}
@Test
void postWithAnAdminJwtIs201() {
given(productService.create(any(Product.class)))
.willAnswer(invocation -> withId(invocation.getArgument(0), 42L));
assertThat(mvc.post().uri("/api/products")
.with(jwt().jwt(token -> token.subject("admin"))
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN")))
.contentType(MediaType.APPLICATION_JSON)
.content(NEW_PRODUCT))
.hasStatus(HttpStatus.CREATED);
}All three passed: 401 with the application's Bearer realm="catalogue" challenge and body, 403 with "title":"Forbidden", 201. The two ways to be somebody in a test differ in what they put in the SecurityContext. A stub that printed the current Authentication from inside the controller call showed it for each default:
@WithMockUser | .with(jwt()) | |
|---|---|---|
| Applies to | the test method or class | one request |
Authentication | UsernamePasswordAuthenticationToken | JwtAuthenticationToken |
| Principal | a User named user | a Jwt named user, built in memory; claims set with .jwt(token -> ...) |
| Default authorities | [ROLE_USER]: the POST got 403 | [SCOPE_read]: the POST got 403 |
| Set roles with | @WithMockUser(roles = "ADMIN") | .authorities(new SimpleGrantedAuthority("ROLE_ADMIN")) |
Token signature, expiry, JwtDecoder | not involved | not involved: no token is encoded or decoded |
jwt() is the closer match for this API, since the application's code sees the same JwtAuthenticationToken type a real bearer token produces. Two details from the runs. jwt().jwt(token -> token.claim("roles", List.of("ADMIN"))) alone also got 403: the post-processor does not run the application's authorities-claim-name and authority-prefix mapping, so the authorities have to be given with .authorities(...). And because neither variant builds a real token, testing the decoder itself (a wrong signature, an expired token) belongs to @SpringBootTest.
The API chain disables CSRF because the bearer token is not an ambient credential, as article 36 explained; a chain with CSRF enabled would need .with(csrf()) on every POST, PUT and DELETE in these tests, as the probe above showed.
An imported SecurityConfig brings every bean in it
@Import(SecurityConfig.class) imports the whole class: passwordEncoder, jwtEncoder, which read the private key successfully, and authenticationManager. That last one misbehaves in the slice. A request with a malformed bearer token, Authorization: Bearer abc.def.ghi, did not produce a 401; the test died with:
java.lang.StackOverflowError
at java.base/java.lang.String.contains(String.java:2981)
at org.springframework.util.ClassUtils.getUserClass(ClassUtils.java:1009)
at org.springframework.core.BridgeMethodResolver.resolveBridgeMethod(BridgeMethodResolver.java:105)
at org.springframework.core.BridgeMethodResolver.findBridgedMethod(BridgeMethodResolver.java:71)
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:356)
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215)
at jdk.proxy3/jdk.proxy3.$Proxy158.authenticate(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:359)
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215)
at jdk.proxy3/jdk.proxy3.$Proxy158.authenticate(Unknown Source)In the full application JpaUserDetailsService exists, and AuthenticationConfiguration.getAuthenticationManager() builds a real ProviderManager from it. In the slice there is no UserDetailsService, the builder produces nothing, and AuthenticationConfiguration in Spring Security 7.1.1 then falls back to a lazy proxy for the AuthenticationManager bean, which is the very bean being defined: the proxy calls itself. The resource server's provider rejected the malformed token, its ProviderManager then asked its parent, the global manager, and the recursion began. The tests above never hit this because jwt() and @WithMockUser bypass authentication. If a web test must send real Authorization headers, keep the filter chain and the authentication beans in separate configuration classes and import only the chain, or test those requests with @SpringBootTest.
@DataJpaTest: repositories on an embedded database
What @DataJpaTest configures
@DataJpaTest is the JPA counterpart of the web slice. Its bytecode in 4.1.1 shows it meta-annotated with @AutoConfigureDataJpa (Spring Data repositories and Hibernate), @AutoConfigureJdbc (DataSource, transaction manager, JdbcTemplate, JdbcClient), @AutoConfigureTestDatabase, @AutoConfigureTestEntityManager and @Transactional, with a type filter that scans only JPA components and a showSql attribute that defaults to true, which sets spring.jpa.show-sql and prints every statement. The context for the catalogue held 83 bean definitions: no controller, no web layer, no security, no ProductService.
The test database is the part to understand. The application points at jdbc:h2:mem:catalog, yet the log of the first @DataJpaTest said:
[ Test worker] beddedDataSourceBeanFactoryPostProcessor : Replacing 'dataSource' DataSource bean with embedded version
[ Test worker] o.s.j.d.e.EmbeddedDatabaseFactory : Starting embedded database: url='jdbc:h2:mem:5a433bfe-9186-4cce-a896-61d72556390f;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false', username='sa'A test that asked the injected DataSource for its URL printed jdbc:h2:mem:5a433bfe-9186-4cce-a896-61d72556390f. @AutoConfigureTestDatabase declares replace with the default Replace.NON_TEST in 4.1.1, which replaces the application's DataSource with a uniquely named embedded one unless that DataSource comes from test infrastructure such as @ServiceConnection. With @AutoConfigureTestDatabase(replace = Replace.NONE) on the class, the same question returned jdbc:h2:mem:catalog through HikariCP, the configured database. NONE is how a slice runs against a real PostgreSQL; H2 is not PostgreSQL, and testing against the real engine with Testcontainers is a topic for the Advanced course.
Every test runs in a transaction that rolls back
@DataJpaTest is @Transactional, so spring-test opens a transaction before each test method and rolls it back afterwards. Two ordered tests prove it by counting rows:
package com.example.demo.product;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
@DataJpaTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ProductRepositoryTest {
@Autowired
TestEntityManager entityManager;
@Autowired
ProductRepository repository;
@Test
@Order(1)
void firstTestInsertsARow() {
entityManager.persistAndFlush(keyboard("KB-001", "89.90"));
System.out.println(">>> rows in first test: " + repository.count());
assertThat(repository.count()).isEqualTo(1);
}
@Test
@Order(2)
void secondTestStartsFromAnEmptyTable() {
System.out.println(">>> rows in second test: " + repository.count());
assertThat(repository.count()).isZero();
}
// the query tests of the next sections go here
private static Product keyboard(String sku, String price) {
return new Product(sku, "Keyboard " + sku, "keyboards", new BigDecimal(price));
}
}With logging.level.org.springframework.test.context.transaction=debug, the transaction around each method is visible:
[ Test worker] o.s.t.c.transaction.TransactionContext : Began transaction (1) for test class [com.example.demo.product.ProductRepositoryTest]; test method [firstTestInsertsARow]; transaction manager [org.springframework.orm.jpa.JpaTransactionManager@18afb626]; rollback [true]
Hibernate: insert into products (category,name,price,sku,id) values (?,?,?,?,default)
Hibernate: select count(*) from products p1_0
>>> rows in first test: 1
Hibernate: select count(*) from products p1_0
[ Test worker] o.s.t.c.transaction.TransactionContext : Rolled back transaction (1) for test class [com.example.demo.product.ProductRepositoryTest]; test method [firstTestInsertsARow]
[ Test worker] o.s.t.c.transaction.TransactionContext : Began transaction (1) for test class [com.example.demo.product.ProductRepositoryTest]; test method [secondTestStartsFromAnEmptyTable]; transaction manager [org.springframework.orm.jpa.JpaTransactionManager@18afb626]; rollback [true]
Hibernate: select count(*) from products p1_0
>>> rows in second test: 0
Hibernate: select count(*) from products p1_0
[ Test worker] o.s.t.c.transaction.TransactionContext : Rolled back transaction (1) for test class [com.example.demo.product.ProductRepositoryTest]; test method [secondTestStartsFromAnEmptyTable]The INSERT really reached H2, the first test counted 1 inside its transaction, and the rollback removed it before the second test counted 0. Each test starts from the schema Hibernate created, not from what the previous test left. To keep a test's changes, annotate the method with @Commit or @Rollback(false).
TestEntityManager and the first-level cache trap
TestEntityManager wraps the test transaction's EntityManager with helpers made for arranging data: persist, persistAndFlush, persistAndGetId, find, flush, clear. Using it for the setup keeps the repository method under test out of the arrangement.
It also exposes the trap every JPA test meets sooner or later. Setup and assertion run in the same transaction and therefore in the same persistence context, the first-level cache. This test saves a price with three decimals into a numeric(10,2) column:
@Test
void priceIsStoredAsSaved() {
Product saved = repository.save(new Product("KB-001", "Keyboard KB-001", "keyboards", new BigDecimal("89.999")));
Product found = repository.findById(saved.getId()).orElseThrow();
assertThat(found).isSameAs(saved);
assertThat(found.getPrice()).isEqualTo(new BigDecimal("89.999"));
}It passes. The SQL log explains why:
Hibernate: insert into products (category,name,price,sku,id) values (?,?,?,?,default)One statement. The INSERT ran at save(), because an IDENTITY key only exists once the row does; findById then found the entity in the persistence context and returned the same Java object without a SELECT. The assertion compared the object with itself and never saw the database. Flushing and clearing the persistence context before reading forces a real load:
@Test
void priceIsStoredAsSavedAfterFlushAndClear() {
Product saved = repository.save(new Product("KB-001", "Keyboard KB-001", "keyboards", new BigDecimal("89.999")));
entityManager.flush();
entityManager.clear();
Product found = repository.findById(saved.getId()).orElseThrow();
assertThat(found.getPrice()).isEqualTo(new BigDecimal("89.999"));
}Hibernate: insert into products (category,name,price,sku,id) values (?,?,?,?,default)
Hibernate: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?org.opentest4j.AssertionFailedError:
expected: 89.999
but was: 90.00H2 rounded the value to the column's scale, and only the SELECT revealed it. flush() sends pending SQL, which for this entity was nothing, since the INSERT had already run; clear() detaches every entity, which is what made findById query. The kept test asserts the stored value:
@Test
@Order(6)
void priceIsRoundedToTheColumnScale() {
Product saved = repository.save(keyboard("KB-001", "89.999"));
entityManager.flush();
entityManager.clear();
Product found = repository.findById(saved.getId()).orElseThrow();
assertThat(found).isNotSameAs(saved);
assertThat(found.getPrice()).isEqualTo(new BigDecimal("90.00"));
}The IDENTITY key also decides when a unique constraint fails. Persisting a second product with SKU KB-001 threw at the second persist, not at a later flush:
org.hibernate.exception.ConstraintViolationException: could not execute statement [Unique index or primary key violation: "PUBLIC.CONSTRAINT_F INDEX PUBLIC.CONSTRAINT_INDEX_F ON PUBLIC.PRODUCTS(SKU NULLS FIRST) VALUES ( /* 1 */ 'KB-001' )"; SQL statement:Changes that Hibernate only writes at flush time behave the other way. A test that persisted a product with persistAndFlush and then called setName with 130 characters, for a varchar(120) column, passed: the log showed the INSERT and no UPDATE, because the rollback discarded the change unsent. With entityManager.flush() after the setter, the UPDATE went out and failed:
org.hibernate.exception.DataException: could not execute statement [Value too long for column "NAME CHARACTER VARYING(120)": "'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx... (130)"; SQL statement:A repository test that changes a managed entity must flush, or the database never checks the change.
A derived query, a @Query and a @Modifying bulk update
The query tests arrange data with TestEntityManager, flush it, and call the repository:
@Test
@Order(3)
void derivedQueryOrdersByPrice() {
entityManager.persist(keyboard("KB-001", "89.90"));
entityManager.persist(keyboard("KB-002", "59.00"));
entityManager.persist(new Product("MS-001", "Wireless mouse", "mice", new BigDecimal("24.50")));
entityManager.flush();
assertThat(repository.findByCategoryOrderByPriceAsc("keyboards"))
.extracting(Product::getSku)
.containsExactly("KB-002", "KB-001");
}
@Test
@Order(4)
void searchIsCaseInsensitive() {
entityManager.persist(new Product("KB-001", "Mechanical Keyboard", "keyboards", new BigDecimal("89.90")));
entityManager.persist(new Product("MS-001", "Wireless mouse", "mice", new BigDecimal("24.50")));
entityManager.flush();
assertThat(repository.search("KEYBOARD"))
.extracting(Product::getSku)
.containsExactly("KB-001");
}
@Test
@Order(5)
void bulkPriceChangeUpdatesMatchingRows() {
Long id = entityManager.persistAndGetId(keyboard("KB-001", "89.90"), Long.class);
entityManager.persist(new Product("MS-001", "Wireless mouse", "mice", new BigDecimal("24.50")));
entityManager.flush();
int updated = repository.changePrices("keyboards", new BigDecimal("1.10"));
assertThat(updated).isEqualTo(1);
assertThat(repository.findById(id)).get()
.extracting(Product::getPrice)
.isEqualTo(new BigDecimal("98.89"));
}The SQL each one sent after its INSERTs:
Hibernate: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.category=? order by p1_0.price
Hibernate: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where lower(p1_0.name) like lower(('%'||?||'%')) escape '' order by p1_0.name
Hibernate: update products p1_0 set price=(p1_0.price*cast(? as numeric(10,2))) where p1_0.category=?
Hibernate: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?The @Modifying query runs because the test transaction is active; article 27 showed the TransactionRequiredException it throws without one, so a repository test would not catch that mistake on its own, while a service test would. The select … where p1_0.id=? after the UPDATE is clearAutomatically = true at work: the persistence context was cleared, so findById read 98.89 from the table instead of returning the cached 89.90, the stale value article 27 demonstrated.
@SpringBootTest: the whole application
MOCK environment with @AutoConfigureMockMvc
@SpringBootTest finds the @SpringBootApplication class and starts the application with every bean: 289 bean definitions here, the JPA stack, the real ProductService, SecurityConfig, UserSeeder. Its default webEnvironment is MOCK: a servlet web context without a server. @AutoConfigureMockMvc adds MockMvc and MockMvcTester on top, so the tests look like the web slice's, but nothing is mocked underneath:
package com.example.demo.product;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import com.example.demo.TestPasswordEncoderConfig;
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.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.transaction.AfterTransaction;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestPasswordEncoderConfig.class)
@Transactional
class ProductApiMockEnvironmentTest {
@Autowired
MockMvcTester mvc;
@Autowired
ProductRepository repository;
@Test
void adminCreatesAProductThroughTheWholeApplication() {
assertThat(mvc.post().uri("/api/products")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN")))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"sku":"HB-001","name":"USB-C hub","category":"hubs","price":35.00}
"""))
.hasStatus(HttpStatus.CREATED);
assertThat(repository.count()).isEqualTo(1);
}
@Test
void unknownIdIsA404FromTheRealService() {
assertThat(mvc.get().uri("/api/products/999"))
.hasStatus(HttpStatus.NOT_FOUND)
.bodyJson()
.extractingPath("$.productId").asNumber().isEqualTo(999);
}
@AfterTransaction
void countRowsAfterRollback() {
System.out.println("products after the test transaction: " + repository.count());
}
}@ActiveProfiles and @Import(TestPasswordEncoderConfig.class) are explained in the test configuration section. @AfterTransaction is a spring-test hook that runs after the test's transaction has ended, outside it. The POST passed through the real chain, controller, service and repository into H2, and the 404 came from the real ProductService finding nothing. The log of the first test, with org.hibernate.SQL at debug:
[ Test worker] o.s.t.c.transaction.TransactionContext : Began transaction (1) for test class [com.example.demo.product.ProductApiMockEnvironmentTest]; test method [adminCreatesAProductThroughTheWholeApplication]; transaction manager [org.springframework.orm.jpa.JpaTransactionManager@6dafdb99]; rollback [true]
[ Test worker] org.hibernate.SQL : insert into products (category,name,price,sku,id) values (?,?,?,?,default)
[ Test worker] o.s.t.c.transaction.TransactionContext : Rolled back transaction (1) for test class [com.example.demo.product.ProductApiMockEnvironmentTest]; test method [adminCreatesAProductThroughTheWholeApplication]
products after the test transaction: 0Every line ran on Test worker. MockMvcTester calls the filter chain and DispatcherServlet on the test's own thread, so ProductService.create, whose @Transactional defaults to REQUIRED, joined the transaction the test had opened, and the test's rollback undid the INSERT.
RANDOM_PORT and RestTestClient
webEnvironment = RANDOM_PORT starts the embedded Tomcat on a free port, and the test becomes a real HTTP client. Which client is available took checking. A RANDOM_PORT test with neither annotation, asking for both through ObjectProvider, printed:
>>> without annotations: RestTestClient=null TestRestTemplate=nullSpring Boot 4.1.1 registers neither by itself. RestTestClient is Spring Framework 7's synchronous test client in spring-test, and @AutoConfigureRestTestClient from spring-boot-resttestclient binds one to the random port; the webmvc test starter already brought that module. @AutoConfigureTestRestTemplate exists in the same module, but in this project the context failed to start with java.lang.NoClassDefFoundError: org/springframework/boot/restclient/RestTemplateBuilder: TestRestTemplate needs the spring-boot-restclient module, which none of the generated starters include. WebTestClient needs Spring WebFlux on the classpath. For a Spring MVC project with these starters, RestTestClient is the client that works out of the box.
An end-to-end test: log in, create, read back
package com.example.demo.product;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import com.example.demo.TestPasswordEncoderConfig;
import com.example.demo.user.LoginRequest;
import com.example.demo.user.TokenResponse;
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.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.transaction.AfterTransaction;
import org.springframework.test.web.servlet.client.RestTestClient;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@ActiveProfiles("test")
@Import(TestPasswordEncoderConfig.class)
@Transactional
class ProductApiRandomPortTest {
@Autowired
RestTestClient client;
@Autowired
ProductRepository repository;
@Test
void adminLogsInCreatesAndReadsAProduct() {
TokenResponse token = client.post().uri("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.body(new LoginRequest("admin", "Admin-2026-secret"))
.exchange()
.expectStatus().isOk()
.expectBody(TokenResponse.class)
.returnResult()
.getResponseBody();
ProductResponse created = client.post().uri("/api/products")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token.accessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(new CreateProductRequest("HB-001", "USB-C hub", "hubs", new BigDecimal("35.00")))
.exchange()
.expectStatus().isCreated()
.expectBody(ProductResponse.class)
.returnResult()
.getResponseBody();
client.get().uri("/api/products/{id}", created.id())
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.sku").isEqualTo("HB-001")
.jsonPath("$.price").isEqualTo(35.00);
}
@AfterTransaction
void countRowsAfterRollback() {
System.out.println("products after the test transaction: " + repository.count());
}
}The URIs are relative: the client Boot configures has the server's base URL. This is the only test in the article where nothing is simulated: AuthenticationManager checked the BCrypt hash from the users table, TokenService signed a real RS256 token with the private key, and the JwtDecoder verified it with the public key on the next request. It passed, against Tomcat started on port 56861 (http) with context path '/'.
@Transactional does not roll back a RANDOM_PORT test
The class carries @Transactional, as the MOCK test did. The log tells a different story:
[ Test worker] o.s.t.c.transaction.TransactionContext : Began transaction (1) for test class [com.example.demo.product.ProductApiRandomPortTest]; test method [adminLogsInCreatesAndReadsAProduct]; transaction manager [org.springframework.orm.jpa.JpaTransactionManager@79f16678]; rollback [true]
[o-auto-1-exec-2] org.hibernate.SQL : insert into products (category,name,price,sku,id) values (?,?,?,?,default)
[ Test worker] o.s.t.c.transaction.TransactionContext : Rolled back transaction (1) for test class [com.example.demo.product.ProductApiRandomPortTest]; test method [adminLogsInCreatesAndReadsAProduct]
products after the test transaction: 1The test's transaction began and rolled back on Test worker, but the INSERT ran on o-auto-1-exec-2, a Tomcat request thread. A Spring transaction is bound to the thread that opened it, so ProductService.create on the server thread found no transaction to join, opened its own, and committed it before the 201 was sent. The rollback on the test thread had nothing of the request's to undo, and the product stayed: one row after the test. The next test sharing that database would find it.
@Transactional on a RANDOM_PORT test only gives a false sense of isolation. Remove it and clean up what the server committed:
import org.junit.jupiter.api.AfterEach;
import org.springframework.test.context.transaction.AfterTransaction;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@ActiveProfiles("test")
@Import(TestPasswordEncoderConfig.class)
@Transactional
class ProductApiRandomPortTest {
@AfterTransaction
void countRowsAfterRollback() {
System.out.println("products after the test transaction: " + repository.count());
}
@AfterEach
void deleteProducts() {
repository.deleteAll();
} repository.deleteAll() runs in a transaction of its own and commits. For larger data sets, a @Sql script after each method or a fresh database per test class does the same job.
Test configuration: @TestConfiguration and @ActiveProfiles
@TestConfiguration for a test-only bean
The login in the end-to-end test hashes a password, and UserSeeder hashes two at startup, all with BCrypt's default cost of 10. Tests do not need that strength. A @TestConfiguration adds a cheaper encoder:
package com.example.demo;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@TestConfiguration(proxyBeanMethods = false)
public class TestPasswordEncoderConfig {
@Bean
@Primary
PasswordEncoder fastPasswordEncoder() {
return new BCryptPasswordEncoder(4);
}
}@TestConfigurationis a@Configurationthat component scanning skips, so it only applies where a test imports it with@Import, or where it is a static nested class of the test.@Primarymakes it win overSecurityConfig.passwordEncoderwherever onePasswordEncoderis injected. With the import, the encoder injected into a test and the hashUserSeederstored foradminboth started with$2a$04$, cost 4.
The bean has its own name on purpose. Named passwordEncoder, the same as the application's bean, the context did not start:
Description:
The bean 'passwordEncoder', defined in com.example.demo.probe.ClashProbeTest$ClashConfig, could not be registered. A bean with that name has already been defined in class path resource [com/example/demo/common/SecurityConfig.class] and overriding is disabled.
Action:
Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=trueSpring Boot disables bean definition overriding, in tests too. Give the test bean another name and @Primary, or replace a bean on purpose with @MockitoBean or @TestBean, which are made for it.
@ActiveProfiles("test") and application-test.properties
@ActiveProfiles("test") activates the test profile for the test's context, and Boot then also loads application-test.properties on top of application.properties, exactly as article 13 described for a running application:
logging.level.org.hibernate.SQL=debugThat one line is what put the org.hibernate.SQL statements, with their thread names, into the @SpringBootTest logs above. Keep the file under src/test/resources with a profile suffix. A src/test/resources/application.properties would sit in front of the main file on the test classpath, and the main one would stop being loaded for tests: in a trial, a @DataJpaTest with such a file saw spring.application.name, spring.datasource.url and app.jwt.private-key-location all as null.
The test context cache: which tests share a context
Turning on the context cache log
spring-test keeps every context it builds in a static cache for the lifetime of the JVM, keyed by the merged configuration of the test class: the annotation, the configuration classes, @Imports, active profiles, properties, the set of @MockitoBean fields and more. A test class with the same key reuses the context; a different key builds a new one. The logging category is org.springframework.test.context.cache. At debug spring-test 7.0.9 logs the cache statistics after every lookup; at trace it adds Storing ApplicationContext [...] in cache under key [...] and Retrieved ApplicationContext [...] from cache with key [...], with the whole merged configuration as the key. Gradle passes it to the test JVM as a system property, which Spring Boot reads like any other property:
tasks.named('test') {
useJUnitPlatform()
systemProperty 'logging.level.org.springframework.test.context.cache', 'debug'
}The run order was made deterministic with JUnit's class orderer:
junit.jupiter.testclass.order.default=org.junit.jupiter.api.ClassOrderer$ClassNameWhich test classes reuse a context
The suite is the five classes of this article. ./gradlew test --rerun writes each class's output into its XML report under build/test-results/test, or to the console with article 37's showStandardStreams = true. The fastest of three runs, at a load average of 6.46:
[context] ProductApiMockEnvironmentTest: loaded in 1745 ms, 289 bean definitions
[context] ProductApiRandomPortTest: loaded in 299 ms, 287 bean definitions
[context] ProductControllerMockMvcTest: loaded in 259 ms, 175 bean definitions
[context] ProductControllerTest: reused from cache, 175 bean definitions
[context] ProductRepositoryTest: loaded in 121 ms, 83 bean definitionsThe last statistics line of the run:
[ Test worker] org.springframework.test.context.cache : Spring test ApplicationContext cache statistics: [DefaultContextCache@57f60b5e size = 4, maxSize = 32, contextUsageCount = 1, parentContextCount = 0, hitCount = 276, missCount = 4, failureCount = 0]missCount = 4,size = 4: four configurations, four contexts built and kept.ProductControllerTestreused the contextProductControllerMockMvcTestbuilt: same@WebMvcTest(ProductController.class), same@Import, same@MockitoBean ProductService.hitCount = 276counts every lookup, and spring-test looks the context up many times per test method, so it is not the number of classes that shared.- The first context took 1745 ms and the others 121 to 299 ms. The first one pays for loading Spring, Hibernate and Tomcat classes into a cold JVM; that cost is paid once per JVM whatever the tests are.
MOCKandRANDOM_PORTnever share:webEnvironmentis part of the key.

A different @MockitoBean set or @DirtiesContext forces a new context
The 500 test first lived in a class of its own, on the full application:
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestPasswordEncoderConfig.class)
class ProductApiFailureTest {
@Autowired
MockMvcTester mvc;
@MockitoBean
ProductService productService;
@Test
void unexpectedErrorIsA500ProblemDetail() {
given(productService.findById(1L)).willThrow(new IllegalStateException("Database is down"));
assertThat(mvc.get().uri("/api/products/1"))
.hasStatus(HttpStatus.INTERNAL_SERVER_ERROR)
.bodyJson()
.extractingPath("$.detail").asString().isEqualTo("An unexpected error occurred.");
}
}Its annotations match ProductApiMockEnvironmentTest exactly, but the @MockitoBean field changes the key. Added to the suite, it built its own 289-bean application context and the cache ended with size = 5, missCount = 5. The second variant marks ProductControllerMockMvcTest with @DirtiesContext, which closes its context and removes it from the cache after the class; ProductControllerTest, next in line, then had to build the same web slice again. Three runs of each suite, each at a load average between 6.4 and 6.8:
| Suite | Contexts built | Suite time, best of 3 | The extra context |
|---|---|---|---|
| The five classes | 4 | 3.24 s | — |
+ ProductApiFailureTest with @MockitoBean | 5 | 3.20 s | a second full application context: 198, 264 and 275 ms |
+ @DirtiesContext on ProductControllerMockMvcTest | 5 | 3.51 s | the web slice again for ProductControllerTest: 143, 159 and 176 ms |
The suite times, summed from the JUnit reports, moved within their own run-to-run spread of about half a second, and the variant with an extra context even had the fastest best run. What each variant added is measurable in the trace: one more context, a quarter of a second for the full application and a sixth for the web slice in a warm JVM. In a catalogue of this size that is noise. It is a fixed cost per distinct configuration, though, and it grows with the application: more entities for Hibernate to map, Flyway migrations to run, a Tomcat per RANDOM_PORT context, and every cached context kept in memory, with its connection pool, until the JVM exits or the cache, capped at maxSize = 32, evicts it. The habits that keep the number of contexts small cost nothing: test an error mapping in the web slice, where the service is mocked anyway, rather than with a @MockitoBean on a @SpringBootTest; give integration tests one shared set of annotations, for example on a base class; and use @DirtiesContext only when a test really leaves a context unusable.
Choosing the test annotation
| Annotation | Loads for this project | Beans | Startup, fresh JVM (load) | Use it for |
|---|---|---|---|---|
| none (article 37) | nothing: new ProductService(mock) | 0 | no context | rules inside a class |
@WebMvcTest(ProductController.class) | MVC, Jackson, validation, advice, one controller, security auto-configuration; your chain only with @Import | 175 | 0.93 s (6.96) | request mapping, binding, validation, status codes, ProblemDetail bodies, URL security rules |
@DataJpaTest | DataSource replaced by embedded H2, Hibernate, repositories, TestEntityManager, rollback per test | 83 | 1.34 s (7.80) | derived queries, @Query, @Modifying, mappings and constraints |
@SpringBootTest + @AutoConfigureMockMvc | everything, no server, requests on the test thread | 289 | 1.71 s (6.87) | the layers together; rollback with @Transactional works |
@SpringBootTest(webEnvironment = RANDOM_PORT) + @AutoConfigureRestTestClient | everything and Tomcat on a random port | 287 | 1.93 s (6.96) | real HTTP end to end; clean up what the server commits |
Several neighbouring tools are outside this article. Testcontainers with @ServiceConnection, which runs the slice or the application against a real PostgreSQL, belongs to the Advanced course, as do contract testing, ArchUnit and performance testing. A RestClient that calls another service, like article 23's, is tested with MockRestServiceServer from spring-test instead of a real remote server. Thymeleaf views, like article 24's, are tested with the same @WebMvcTest and MockMvcTester, asserting hasViewName and the model instead of JSON.
FAQ
What replaces @MockBean in Spring Boot 4?
@MockitoBean from org.springframework.test.context.bean.override.mockito in spring-test, and @MockitoSpyBean for @SpyBean. @MockBean was deprecated in Spring Boot 3.4 and no Spring Boot 4.1.1 jar contains it, so the old import fails with package org.springframework.boot.test.mock.mockito does not exist. A @MockitoBean field replaces the bean of its type in the test's context, or registers one when none exists, and is reset after each test method.
Why does my @WebMvcTest ignore my SecurityFilterChain?
Because a @Configuration class is not a web component, and the slice's component scan only includes controllers, advice, converters, filters and similar types. The slice still includes Spring Security's auto-configuration, so it runs Boot's default chain: with a resource server configured, every request needs authentication and CSRF is on, which turned a public GET into 401 and a POST into 403 with empty bodies. Add @Import with your security configuration class and every bean its filter chain depends on.
Why is the package of @WebMvcTest and @DataJpaTest different in Spring Boot 4?
Spring Boot 4 split its auto-configuration and test support into modules per technology. @WebMvcTest and @AutoConfigureMockMvc are now in org.springframework.boot.webmvc.test.autoconfigure (spring-boot-webmvc-test), @DataJpaTest in org.springframework.boot.data.jpa.test.autoconfigure, @AutoConfigureTestDatabase in org.springframework.boot.jdbc.test.autoconfigure and TestEntityManager in org.springframework.boot.jpa.test.autoconfigure. The per-feature test starters that Spring Initializr generates bring the right modules.
Does @DataJpaTest use my configured database?
Not by default. @AutoConfigureTestDatabase defaults to replace = Replace.NON_TEST, which replaced jdbc:h2:mem:catalog with an embedded H2 database named by a random UUID. Add @AutoConfigureTestDatabase(replace = Replace.NONE) to use the configured DataSource, which is what a slice test against a real database server needs.
Why does @Transactional not roll back my @SpringBootTest with RANDOM_PORT?
The request is handled on a Tomcat thread, and a Spring transaction belongs to the thread that started it. The server's service method opened and committed its own transaction; the test's transaction on the Test worker thread rolled back nothing of it, and a product created in the test was still in the table afterwards. In the default MOCK environment the request runs on the test thread, joins the test transaction and is rolled back. With RANDOM_PORT, delete test data explicitly.
Should I use TestRestTemplate or RestTestClient in Spring Boot 4?
In a Spring MVC project generated with the webmvc test starter, RestTestClient with @AutoConfigureRestTestClient works without extra dependencies. TestRestTemplate moved to org.springframework.boot.resttestclient, needs @AutoConfigureTestRestTemplate, and in this project failed with NoClassDefFoundError: org/springframework/boot/restclient/RestTemplateBuilder until the spring-boot-restclient module is added. Neither client is registered for a RANDOM_PORT test without its annotation.
Conclusion
Spring Boot testing is a choice of how much application to start. @WebMvcTest starts the web layer: controller, advice, Jackson and validation, with @MockitoBean in place of the service and MockMvcTester to drive requests and assert status, headers and JSON through AssertJ. Security in that slice is Spring Boot's default until the application's chain is imported with the beans it needs, and @WithMockUser or jwt() then decide who is calling. @DataJpaTest starts JPA on an embedded database, wraps each test in a transaction that rolls back, and rewards a flush() and clear() before every assertion that should see the table rather than the first-level cache. @SpringBootTest starts everything: on the test thread with MOCK, where @Transactional rolls back, or behind a real Tomcat with RANDOM_PORT and RestTestClient, where it cannot.
Boot 4 moved the annotations into per-feature modules and replaced @MockBean with @MockitoBean, and the context cache decides what a suite costs: each distinct combination of annotations, imports, profiles and mocks is one more context to build and keep.
That closes Chapter 6. Chapter 7 is about tools that make everyday work faster, and article 39 opens it with Spring Boot DevTools, Lombok with its trade-offs, and the basics of Actuator with /health and /info.