Command Palette

Search for a command to run...

[Spring Boot Basics] Testing with Spring Boot: @SpringBootTest, @WebMvcTest with MockMvcTester and @DataJpaTest

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 whole application as a dashed frame holding two slices, MVC and JPA, each with a green check

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.

Tree
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.java

The entity maps the products table with an IDENTITY key, a unique sku and a numeric(10,2) price:

src/main/java/com/example/demo/product/Product.java
@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:

src/main/java/com/example/demo/product/ProductRepository.java
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:

src/main/java/com/example/demo/product/ProductService.java
@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);
    }
}
src/main/java/com/example/demo/product/ProductController.java
@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:

src/main/java/com/example/demo/common/SecurityConfig.java
@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:

src/main/resources/application.properties
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.pem

Test 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:

build.gradle
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 starterBringsGives 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-testspring-boot-webmvc-test, spring-boot-resttestclient@WebMvcTest, @AutoConfigureMockMvc, @AutoConfigureRestTestClient
spring-boot-starter-data-jpa-testspring-boot-data-jpa-test, spring-boot-jpa-test, spring-boot-jdbc-test@DataJpaTest, TestEntityManager, @AutoConfigureTestDatabase
spring-boot-starter-security-testspring-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:

src/test/java/com/example/demo/product/ProductControllerTest.java
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
src/test/java/com/example/demo/product/ProductRepositoryTest.java
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;
Bash
./gradlew compileTestJava
Text
> 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.jdbc

The 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:

TypeSpring Boot 3 importSpring Boot 4.1.1 importJar
@WebMvcTestorg.springframework.boot.test.autoconfigure.web.servletorg.springframework.boot.webmvc.test.autoconfigurespring-boot-webmvc-test
@AutoConfigureMockMvcorg.springframework.boot.test.autoconfigure.web.servletorg.springframework.boot.webmvc.test.autoconfigurespring-boot-webmvc-test
@DataJpaTestorg.springframework.boot.test.autoconfigure.orm.jpaorg.springframework.boot.data.jpa.test.autoconfigurespring-boot-data-jpa-test
@AutoConfigureTestDatabaseorg.springframework.boot.test.autoconfigure.jdbcorg.springframework.boot.jdbc.test.autoconfigurespring-boot-jdbc-test
TestEntityManagerorg.springframework.boot.test.autoconfigure.orm.jpaorg.springframework.boot.jpa.test.autoconfigurespring-boot-jpa-test
@MockBeanorg.springframework.boot.test.mock.mockito.MockBean@MockitoBean in org.springframework.test.context.bean.override.mockitospring-test
TestRestTemplateorg.springframework.boot.test.web.clientorg.springframework.boot.resttestclientspring-boot-resttestclient
@SpringBootTestorg.springframework.boot.test.contextunchangedspring-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:

src/test/java/com/example/demo/ContextStatsListener.java
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;
    }
}
src/test/resources/META-INF/spring.factories
org.springframework.test.context.TestExecutionListener=com.example.demo.ContextStatsListener

Each 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 classAnnotationBean definitionsStarted … in (best of 3)ListenerLoad average
ProductControllerTest@WebMvcTest(ProductController.class) + security @Import1750.932 s943 ms6.96
ProductRepositoryTest@DataJpaTest831.335 s1349 ms7.80
ProductApiMockEnvironmentTest@SpringBootTest + @AutoConfigureMockMvc2891.707 s1803 ms6.87
ProductApiRandomPortTest@SpringBootTest(webEnvironment = RANDOM_PORT) + @AutoConfigureRestTestClient2871.931 s2025 ms6.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.

What @WebMvcTest, @DataJpaTest and @SpringBootTest put into the context for the catalogue, with the bean counts and startup times measured for each

@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:

src/test/java/com/example/demo/product/ProductControllerTest.java
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();
    }
}
Bash
./gradlew test

With Gradle's default test logging, the console shows only the exception chain:

Text
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:2304

The test report holds Spring Boot's failure analysis:

Text
***************************
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:

src/test/java/com/example/demo/product/ProductControllerTest.java
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.

MockMvcTester builds a request that passes the security filter chain and DispatcherServlet on the test thread, with no Tomcat and no socket, reaches the controller and the @MockitoBean stub, and returns a response to AssertJ; beside it, the RANDOM_PORT path through RestTestClient, a real socket and a Tomcat thread that commits its own transaction

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):

src/test/java/com/example/demo/product/ProductControllerTest.java
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() and hasContentType(...) check the response itself.
  • bodyJson() switches to JSON assertions on the body. isLenientlyEqualTo compares with JSONassert in lenient mode: the fields listed must match, category may be there without being mentioned, and array order is not enforced. isStrictlyEqualTo enforces both.
  • ReflectionTestUtils.setField gives the entity an id without a setter, which only JPA normally assigns.

JSON paths reach into a list:

src/test/java/com/example/demo/product/ProductControllerTest.java
    @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:

src/test/java/com/example/demo/product/ProductControllerTest.java
    @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:

src/test/java/com/example/demo/product/ProductControllerTest.java
    @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:

src/test/java/com/example/demo/product/ProductControllerTest.java
    @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:

src/test/java/com/example/demo/product/ProductControllerTest.java
    @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:

src/test/java/com/example/demo/product/ProductControllerTest.java
    @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:

src/test/java/com/example/demo/product/ProductControllerMockMvcTest.java
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:

Text
org.opentest4j.AssertionFailedError: [HTTP status code] 
expected: 201
 but was: 200

and MockMvc:

Text
java.lang.AssertionError: Status expected:<201> but was:<200>

Expecting KB-002 at $.sku, MockMvcTester said:

Text
org.opentest4j.AssertionFailedError: 
expected: "KB-002"
 but was: "KB-001"

and MockMvc:

Text
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, content or jsonPath imports from three builder and matcher classes; the request starts from mvc and the checks are ordinary AssertJ.
  • No throws Exception. perform declares it; MockMvcTester does not.
  • Typed values. extractingPath(...).asNumber(), asArray().containsExactly(...) and convertTo(ProductResponse.class) give the whole AssertJ API to a value, where jsonPath(...).value(...) checks one expected value.
  • Whole-document comparison. isLenientlyEqualTo checks 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:

Text
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:

RequestStatusWWW-AuthenticateBody
GET /api/products/1401Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource"empty
POST /api/products403noneempty
POST /api/products with spring-security-test's csrf()401Bearer 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:

Text
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:

src/test/java/com/example/demo/product/ProductControllerTest.java
@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:

src/test/java/com/example/demo/product/ProductControllerTest.java
    @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 tothe test method or classone request
AuthenticationUsernamePasswordAuthenticationTokenJwtAuthenticationToken
Principala User named usera 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, JwtDecodernot involvednot 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:

Text
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:

Text
[    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:

src/test/java/com/example/demo/product/ProductRepositoryTest.java
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:

Text
[    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:

src/test/java/com/example/demo/product/ProductRepositoryTest.java
    @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:

Text
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:

src/test/java/com/example/demo/product/ProductRepositoryTest.java
    @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"));
    }
Text
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=?
Text
org.opentest4j.AssertionFailedError: 
expected: 89.999
 but was: 90.00

H2 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:

src/test/java/com/example/demo/product/ProductRepositoryTest.java
    @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:

Text
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:

Text
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:

src/test/java/com/example/demo/product/ProductRepositoryTest.java
    @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:

Text
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:

src/test/java/com/example/demo/product/ProductApiMockEnvironmentTest.java
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:

Text
[    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: 0

Every 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:

Text
>>> without annotations: RestTestClient=null TestRestTemplate=null

Spring 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

src/test/java/com/example/demo/product/ProductApiRandomPortTest.java
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:

Text
[    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: 1

The 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:

src/test/java/com/example/demo/product/ProductApiRandomPortTest.java
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:

src/test/java/com/example/demo/TestPasswordEncoderConfig.java
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);
    }
}
  • @TestConfiguration is a @Configuration that 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.
  • @Primary makes it win over SecurityConfig.passwordEncoder wherever one PasswordEncoder is injected. With the import, the encoder injected into a test and the hash UserSeeder stored for admin both 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:

Text
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=true

Spring 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:

src/test/resources/application-test.properties
logging.level.org.hibernate.SQL=debug

That 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:

build.gradle
tasks.named('test') {
    useJUnitPlatform()
    systemProperty 'logging.level.org.springframework.test.context.cache', 'debug'
}

The run order was made deterministic with JUnit's class orderer:

src/test/resources/junit-platform.properties
junit.jupiter.testclass.order.default=org.junit.jupiter.api.ClassOrderer$ClassName

Which 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:

Text
[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 definitions

The last statistics line of the run:

Text
[    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.
  • ProductControllerTest reused the context ProductControllerMockMvcTest built: same @WebMvcTest(ProductController.class), same @Import, same @MockitoBean ProductService.
  • hitCount = 276 counts 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.
  • MOCK and RANDOM_PORT never share: webEnvironment is part of the key.

Five test classes run in order; identical configurations reuse one cached context, while a @SpringBootTest with an extra @MockitoBean and a @DirtiesContext each force one more context to be built, with the load times from the runs

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:

src/test/java/com/example/demo/product/ProductApiFailureTest.java
@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:

SuiteContexts builtSuite time, best of 3The extra context
The five classes43.24 s
+ ProductApiFailureTest with @MockitoBean53.20 sa second full application context: 198, 264 and 275 ms
+ @DirtiesContext on ProductControllerMockMvcTest53.51 sthe 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

AnnotationLoads for this projectBeansStartup, fresh JVM (load)Use it for
none (article 37)nothing: new ProductService(mock)0no contextrules inside a class
@WebMvcTest(ProductController.class)MVC, Jackson, validation, advice, one controller, security auto-configuration; your chain only with @Import1750.93 s (6.96)request mapping, binding, validation, status codes, ProblemDetail bodies, URL security rules
@DataJpaTestDataSource replaced by embedded H2, Hibernate, repositories, TestEntityManager, rollback per test831.34 s (7.80)derived queries, @Query, @Modifying, mappings and constraints
@SpringBootTest + @AutoConfigureMockMvceverything, no server, requests on the test thread2891.71 s (6.87)the layers together; rollback with @Transactional works
@SpringBootTest(webEnvironment = RANDOM_PORT) + @AutoConfigureRestTestClienteverything and Tomcat on a random port2871.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.

Related Posts

[Spring Boot Basics] JPA Auditing in Spring Boot: @CreatedDate, @LastModifiedDate and @CreatedBy

Spring Data JPA auditing on Spring Boot 4.1.1 with PostgreSQL: @EnableJpaAuditing, AuditingEntityListener and a @MappedSuperclass base class, a Flyway migration adding NOT NULL audit columns to a table with rows, the silent nulls without the annotation or the listener, Instant vs LocalDateTime vs OffsetDateTime and what timestamptz stores, when @LastModifiedDate moves and what modifyOnCreate changes, the detached save() that writes null into created_at and @Column(updatable = false), @CreatedBy from an X-User header through AuditorAware, a Clock-backed DateTimeProvider, the bulk and native updates that bypass auditing, and Hibernate @CreationTimestamp and @UpdateTimestamp compared.

[Spring Boot Basics] Logging in Spring Boot: SLF4J, Logback, Log Levels and Log Files

Logging in Spring Boot 4.1.1: SLF4J as the facade and Logback as the implementation, the jul-to-slf4j and log4j-to-slf4j bridges, parameterised and fluent logging, exceptions, log levels, the logger hierarchy and log groups, --debug versus --trace, the default log line pattern, logging.file.name with rotation, logback-spring.xml with springProfile, MDC and switching to Log4j2.

[Spring Boot Basics] Unit Testing in Spring Boot: JUnit 6, AssertJ and Mockito for the Service Layer

Unit testing the service layer of a Spring Boot 4.1.1 application with JUnit, AssertJ and Mockito: what a unit test replaces, the Gradle test task and its report, a new test instance per method proven by identity, @Nested and parameterized display names in JUnit 6, the BigDecimal isEqualTo trap and soft assertions with their failure messages, @Mock with constructor injection versus @InjectMocks passing null, stubbing, verify and ArgumentCaptor, UnnecessaryStubbingException and PotentialStubbingProblem under strict stubs, a fixed Clock, and loading Mockito as a -javaagent to remove the self-attaching warning.

[Spring Boot Basics] Setting Up Spring Boot: JDK, IDE, Spring Initializr and Your First Application

Install JDK 21 on macOS, Windows and Linux, fix a JAVA_HOME pointing at the wrong JDK, compare IntelliJ IDEA with VS Code, generate a Spring Boot 4.1.1 project from Spring Initializr or one curl command, run it with the Gradle wrapper, read the startup log line by line, write a @RestController that returns JSON, change server.port, and fix the five errors every beginner hits.