Command Palette

Search for a command to run...

[Spring Boot Basics] Test với Spring Boot: @SpringBootTest, @WebMvcTest với MockMvcTester và @DataJpaTest

Bài 37 test ProductService như một object Java thuần: JUnit, AssertJ và một mock Mockito cho repository, không có Spring ở đâu cả. Cách đó chứng minh được các rule bên trong service, nhưng không chứng minh được những phần Spring nối lại với nhau: POST /api/products có bind và validate body đúng không, security filter chain có trả 401 trước khi controller chạy không, advice có biến exception thành đúng ProblemDetail không, derived query có gửi đúng câu SQL bạn nghĩ không. Những điều đó cần một application context, và Spring Boot cho bạn chọn khởi động bao nhiêu phần của nó: một slice chỉ có tầng web (@WebMvcTest), một slice chỉ có JPA (@DataJpaTest), hoặc toàn bộ ứng dụng (@SpringBootTest).

Các ví dụ dùng Spring Boot 4.1.1 và Java 21, trên một project Initializr có các dependency web, validation, security, oauth2-resource-server, data-jpah2. Các số đo thời gian chỉ mang tính tham khảo, và mỗi số đều ghi kèm load average một phút lúc đo.

Toàn bộ ứng dụng là một khung nét đứt chứa hai slice, MVC và JPA, mỗi slice có một dấu check xanh

Các phần đi từ context nhỏ nhất đến lớn nhất, rồi xem điều gì quyết định một bộ test như vậy chạy nhanh hay chậm: test context cache.

Ứng dụng catalogue dùng để test

Test cần một thứ đủ thật để test, nên project là phiên bản rút gọn của catalogue từ Chương 3 đến 5: endpoint cho product với DTO record, GlobalExceptionHandler của bài 20, các query repository của bài 27, và JWT resource server của bài 35 và 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

Entity map bảng products với key IDENTITY, sku unique và price kiểu numeric(10,2):

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
}

Repository có mỗi loại query mà bài 27 đã dạy một cái:

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);
}

Service và 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 là một record với @NotBlank trên sku, namecategory, @NotNull @Positive trên price; ProductResponse chứa id, sku, name, categoryprice. GlobalExceptionHandler extends ResponseEntityExceptionHandler và trả 404 cho ProductNotFoundException, 409 cho DuplicateSkuException, 422 kèm danh sách errors đã sắp xếp cho body không hợp lệ, 500 từ một catch-all, và rethrow AccessDeniedException như bài 36 đã chỉ ra. SecurityConfig chứa API chain của bài 36 và các bean mà bài 34 và 35 thêm vào:

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 xác thực với bảng users qua JpaUserDetailsService và trả về {"accessToken":…,"tokenType":"Bearer","expiresIn":900} từ TokenService; UserSeeder, một CommandLineRunner, insert alice với role USERadmin với role ADMIN. Cấu hình:

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 starter và import trong Spring Boot 4

Test dependency mà Spring Initializr tạo ra

Bài 37 đã đi qua file build này: mỗi starter chính có một test starter tương ứng, JUnit Platform launcher, useJUnitPlatform(), và spring-boot-starter-test mang theo JUnit 6.0.3, AssertJ và Mockito. Project này có thêm starter security và resource server, nên có thêm hai test starter:

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'
}

Điều quan trọng với bài này là mỗi test starter mang theo module Boot nào, tức là annotation nào. ./gradlew dependencies --configuration testRuntimeClasspath cho thấy điều đó:

Test starterMang theoCho bạn
spring-boot-starter-test (transitive, từ mọi test starter)spring-boot-test, spring-boot-test-autoconfigure, spring-test 7.0.9, JsonPath 2.10.0, JSONassert 1.5.3, cùng JUnit, AssertJ và Mockito của bài 37@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 cho MockMvc

Cảnh báo của JVM A Java agent has been loaded dynamically, do Mockito gây ra và đã được bài 37 xử lý bằng -javaagent, cũng xuất hiện trong các lần chạy này; cách sửa y hệt.

Import của Boot 3 không còn compile được

Các test annotation đã chuyển vào những module đó và package của chúng đổi theo. Một web test và một repository test viết với import của Boot 3:

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

Build báo 10 lỗi; sáu lỗi còn lại là cannot find symbol trên các annotation và field dùng những import đó, và đường dẫn được rút gọn về gốc project. Import jdbc lỗi theo kiểu khác vì package org.springframework.boot.test.autoconfigure.jdbc vẫn tồn tại trong 4.1.1 cho các class khác, chỉ là không còn AutoConfigureTestDatabase. Vị trí mới, kiểm tra bằng cách liệt kê các jar 4.1.1:

TypeImport của Spring Boot 3Import của Spring Boot 4.1.1Jar
@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 trong org.springframework.test.context.bean.override.mockitospring-test
TestRestTemplateorg.springframework.boot.test.web.clientorg.springframework.boot.resttestclientspring-boot-resttestclient
@SpringBootTestorg.springframework.boot.test.contextkhông đổispring-boot-test

@MockBean không phải là class bị chuyển chỗ: không jar 4.1.1 nào chứa nó. Thứ thay thế nó, @MockitoBean, thuộc cơ chế bean override của Spring Framework trong spring-test, cạnh @MockitoSpyBean, thứ thay cho @SpyBean.

Mỗi test load bao nhiêu phần của ứng dụng?

Một test có thể bắt đầu ở bất kỳ điểm nào trên một dải. Một đầu là unit test của bài 37: service tạo bằng new, repository thay bằng mock Mockito, không có context nào, ba test của ProductServiceTest chạy trong 5 ms. Đầu kia là @SpringBootTest, khởi động ứng dụng như main sẽ làm. Ở giữa, một slice khởi động một tầng: @WebMvcTest tắt auto-configuration trừ các phần web, security và JSON, và chỉ scan các web component; @DataJpaTest làm điều tương tự cho JPA. Ít bean hơn thường nghĩa là khởi động nhanh hơn, nhưng không phải lúc nào cũng vậy, và cách duy nhất để biết với một project cụ thể là đo.

Một TestExecutionListener in ra, với mỗi test class, thời gian lấy context và số bean definition mà context chứa:

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

Mỗi test class của các phần sau được chạy riêng, trong một JVM mới, ba lần với ./gradlew test --rerun --tests <class>. Lần chạy tốt nhất của mỗi class, cạnh dòng Started … in của chính Spring Boot trong cùng lần chạy:

Test classAnnotationBean definitionStarted … in (tốt nhất trong 3)ListenerLoad average
ProductControllerTest@WebMvcTest(ProductController.class) + @Import security1750.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

JPA slice có ít bean nhất mà vẫn khởi động chậm hơn web slice: phần lớn thời gian của nó là Hibernate dựng metamodel và tạo schema, không phải số bean. Các con số của context đầu tiên này bao gồm cả thời gian JVM khởi động nóng máy; trong một bộ test đang chạy, chính các context đó load chỉ mất một phần nhỏ thời gian như vậy, như phần context cache sẽ cho thấy.

@WebMvcTest, @DataJpaTest và @SpringBootTest đưa gì vào context của catalogue, kèm số bean và thời gian khởi động đo được cho mỗi loại

@WebMvcTest với MockMvcTester

Web slice load những gì

@WebMvcTest(ProductController.class) khởi động Spring MVC mà không có server. Nó chỉ import các auto-configuration dành cho web test; context bị lỗi bên dưới đã in chúng ra trong cache key của nó: WebMvcAutoConfiguration, ErrorMvcAutoConfiguration, HttpMessageConvertersAutoConfiguration, JacksonAutoConfiguration, ValidationAutoConfiguration, MockMvcAutoConfiguration, các auto-configuration của Spring Security và resource server, và vài cái nữa, tổng cộng 19, không có DataSource, không JPA và không Tomcat. Component scan được lọc chỉ còn các web type: class @Controller (chỉ ProductController, vì nó được ghi tên), @ControllerAdvice, WebMvcConfigurer, Filter, converter, HandlerInterceptor và Jackson module. Các class @Service, @Repository, @Component@Configuration bị bỏ ra ngoài.

Test đầu tiên chỉ yêu cầu một 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

Với test logging mặc định của Gradle, console chỉ in chuỗi exception:

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

Test report chứa failure analysis của Spring Boot:

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.

Controller được tạo, service của nó thì không: slice đã bỏ ProductService ra ngoài, và cùng với nó là repository và database mà service cần.

@MockitoBean cung cấp service

@MockitoBean đăng ký một mock Mockito vào context theo type của field, nên controller nhận nó qua constructor và test stub nó:

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; 

Có nó, context khởi động được, và một debug test liệt kê các bean của ứng dụng: demoApplication, productController, globalExceptionHandler và mock, được đăng ký với tên com.example.demo.product.ProductService#0, cạnh jacksonJsonMapper, defaultValidator, mvcValidator, mockMvc, mockMvcTesterjwtDecoderByPublicKeyValue. Không có productRepository, entityManagerFactory, dataSource, authController, tokenService hay userSeeder, và cũng không có securityConfig, điều quan trọng ở phần security. Mock chỉ thay bean cho context này, và Spring reset nó sau mỗi test method, nên stub của test này không lọt sang test sau.

MockMvcTester tạo một request đi qua security filter chain và DispatcherServlet trên chính thread của test, không Tomcat và không socket, tới controller và stub @MockitoBean rồi trả response cho AssertJ; bên cạnh là đường đi RANDOM_PORT qua RestTestClient, một socket thật và một thread Tomcat commit transaction của riêng nó

GET: kiểm tra status và JSON

MockMvcTester là điểm vào AssertJ của Spring Framework cho MockMvc, và Boot đăng ký một bean của nó trong mọi context có MockMvc khi AssertJ nằm trên classpath. mvc.get().uri(...) tạo request, còn assertThat(...) thực thi nó và trả về một assertion object cho kết quả. Test class đầy đủ, gồm cả các import security (phần security sẽ giải thích):

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()hasContentType(...) kiểm tra chính response.
  • bodyJson() chuyển sang assertion JSON trên body. isLenientlyEqualTo so sánh bằng JSONassert ở chế độ lenient: các field được liệt kê phải khớp, category có thể có mặt mà không cần nhắc tới, và thứ tự trong array không bị ép. isStrictlyEqualTo ép cả hai.
  • ReflectionTestUtils.setField gán id cho entity mà không cần setter, vì bình thường chỉ JPA gán id.

JSON path đi vào bên trong một 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 tính một biểu thức JsonPath và trả về assertion trên giá trị đó, và asArray(), asString(), asNumber(), asBoolean(), asMap() biến nó thành type AssertJ tương ứng. hasPathSatisfying giữ chuỗi assertion trên cả document. Body cũng có thể được đọc ngược lại thành response record bằng chính JsonMapper của ứng dụng:

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 kèm body: 201 và Location

Một thao tác ghi cần role ADMIN mà chain yêu cầu; ở đây @WithMockUser cung cấp nó, và phần security sẽ so sánh nó với JWT. any(Product.class) khớp với entity mà controller tạo từ JSON, và stub trả lại chính nó kèm 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);
    }

Request không hề chạm tới network. http://localhost trong header Location là server name mặc định của MockHttpServletRequest với port 80, được ServletUriComponentsBuilder.fromCurrentRequest() đọc như đọc một request thật.

Response ProblemDetail: 404, 409, 422 và 500

Advice là một phần của slice, nên các response lỗi được test đúng như client nhận được. Stub của service ném exception; controller không catch; GlobalExceptionHandler ghi 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"}
                        """);
    }

422 không cần stub nào, vì validation chạy trước controller method. Dòng cuối chứng minh service chưa bao giờ được gọi:

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();
    }

Một exception bất ngờ đi tới 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.");
    }

Test pass, và log của test có đúng dòng của advice, ERROR … c.e.demo.common.GlobalExceptionHandler : Unhandled exception on GET /api/products/1, kèm stack trace, y như ở production.

MockMvc cổ điển: perform và andExpect

Cùng context đó cũng có bean MockMvc, API mà các tutorial viết trước Spring Framework 6.2 dùng. Test GET theo kiểu đó:

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));
    }
}

Cả hai đều pass. Bốn assertion cố tình sai cho thấy mỗi cách báo lỗi ra sao. Khi chờ 201 cho GET, MockMvcTester báo:

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

còn MockMvc:

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

Khi chờ KB-002$.sku, MockMvcTester báo:

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

còn MockMvc:

Text
java.lang.AssertionError: JSON path "$.sku" expected:<KB-002> but was:<KB-001>

Các thông báo rõ ràng như nhau, và với JSON path thì bản cổ điển còn ghi tên path. Lý do series dùng MockMvcTester nằm ở chỗ khác:

  • Một kiểu import. Không cần static import get, status, content hay jsonPath từ ba class builder và matcher; request bắt đầu từ mvc và các phép kiểm tra là AssertJ bình thường.
  • Không throws Exception. perform khai báo nó; MockMvcTester thì không.
  • Giá trị có type. extractingPath(...).asNumber(), asArray().containsExactly(...)convertTo(ProductResponse.class) đưa cả API AssertJ tới một giá trị, trong khi jsonPath(...).value(...) chỉ kiểm tra một giá trị mong đợi.
  • So sánh cả document. isLenientlyEqualTo kiểm tra một JSON object trong một câu lệnh.

Các test MockMvc hiện có vẫn chạy bình thường, và hai kiểu có thể sống chung trong cùng một context.

Security trong @WebMvcTest

Không @Import: chain resource server mặc định của Spring Boot

@WebMvcTest đầu tiên ở trên không có cấu hình security của riêng nó, vậy mà vẫn có security. SecurityConfig là một class @Configuration, thứ mà filter của slice bỏ ra ngoài, nhưng slice lại có auto-configuration của Spring Security và resource server. Vì public-key-location đã được set, Boot tự dựng chain mặc định của nó, và một debug test in ra:

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]

Chain đó yêu cầu xác thực cho mọi request và giữ CSRF protection bật. Với @MockitoBean ProductService và không có @Import, các request không kèm token nhận được:

RequestStatusWWW-AuthenticateBody
GET /api/products/1401Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource"rỗng
POST /api/products403không córỗng
POST /api/products kèm csrf() của spring-security-test401Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource"rỗng

Không cái nào là câu trả lời của ứng dụng. GET công khai lẽ ra phải là 200. POST bị CsrfFilter từ chối trước cả khi xét tới xác thực, điều mà dòng thứ ba xác nhận: có CSRF token thì cùng request đó đi tới bước xác thực và nhận 401. Và không body nào là ProblemDetail, vì entry point của ứng dụng không có mặt. Một web test pass với chain này là đang test các giá trị mặc định của Spring Boot, không phải rule của bạn.

@Import SecurityConfig và những gì nó cần

@Import thêm các configuration class vào slice. Chỉ import SecurityConfig:

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 là một @Component, nên slice cũng bỏ qua nó. Đưa cả hai vào:

src/test/java/com/example/demo/product/ProductControllerTest.java
@WebMvcTest(ProductController.class)
@Import({SecurityConfig.class, ProblemDetailSecurityHandler.class}) 
class ProductControllerTest {

jwtSecurityFilterChain của Boot lùi lại ngay khi ứng dụng định nghĩa một SecurityFilterChain, nên giờ slice chạy apiSecurityFilterChain: đọc công khai, ghi cần ADMIN, CSRF tắt, 401 và 403 dưới dạng ProblemDetail. JsonMapper của handler đến từ JacksonAutoConfiguration, thứ mà slice có, còn JwtDecoder đến từ auto-configuration của resource server.

@WithMockUser và jwt(): 401, 403 và 201

Ba test ghim các rule từ bên ngoài:

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);
    }

Cả ba đều pass: 401 với challenge Bearer realm="catalogue" và body của ứng dụng, 403 với "title":"Forbidden", 201. Hai cách đóng vai một người dùng trong test khác nhau ở thứ chúng đặt vào SecurityContext. Một stub in ra Authentication hiện tại từ bên trong lời gọi controller đã cho thấy điều đó với giá trị mặc định của mỗi cách:

@WithMockUser.with(jwt())
Áp dụng chotest method hoặc test classmột request
AuthenticationUsernamePasswordAuthenticationTokenJwtAuthenticationToken
Principalmột User tên usermột Jwt tên user, tạo trong bộ nhớ; claim đặt bằng .jwt(token -> ...)
Authority mặc định[ROLE_USER]: POST nhận 403[SCOPE_read]: POST nhận 403
Đặt role bằng@WithMockUser(roles = "ADMIN").authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))
Chữ ký token, hạn dùng, JwtDecoderkhông liên quankhông liên quan: không token nào được encode hay decode

jwt() sát với API này hơn, vì code của ứng dụng thấy cùng type JwtAuthenticationToken mà một bearer token thật tạo ra. Hai chi tiết từ các lần chạy. Chỉ jwt().jwt(token -> token.claim("roles", List.of("ADMIN"))) thôi cũng nhận 403: post-processor không chạy phần map authorities-claim-nameauthority-prefix của ứng dụng, nên authority phải được đưa vào bằng .authorities(...). Và vì không cách nào tạo token thật, việc test chính decoder (sai chữ ký, token hết hạn) thuộc về @SpringBootTest.

API chain tắt CSRF vì bearer token không phải credential mà browser tự gắn, như bài 36 đã giải thích; một chain bật CSRF sẽ cần .with(csrf()) trên mọi POST, PUT và DELETE trong các test này, như phép thử ở trên đã cho thấy.

SecurityConfig được import kéo theo mọi bean bên trong

@Import(SecurityConfig.class) import cả class: passwordEncoder, jwtEncoder, thứ đọc private key thành công, và authenticationManager. Cái cuối cùng hoạt động sai trong slice. Một request có bearer token sai định dạng, Authorization: Bearer abc.def.ghi, không nhận được 401; test chết với:

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)

Trong ứng dụng đầy đủ có JpaUserDetailsService, và AuthenticationConfiguration.getAuthenticationManager() dựng một ProviderManager thật từ nó. Trong slice không có UserDetailsService, builder không tạo ra gì, và AuthenticationConfiguration của Spring Security 7.1.1 khi đó quay sang một lazy proxy cho bean AuthenticationManager, mà đó chính là bean đang được định nghĩa: proxy gọi lại chính nó. Provider của resource server từ chối token sai định dạng, ProviderManager của nó hỏi tiếp parent, tức manager toàn cục, và vòng đệ quy bắt đầu. Các test ở trên không gặp điều này vì jwt()@WithMockUser bỏ qua bước xác thực. Nếu một web test buộc phải gửi header Authorization thật, hãy tách filter chain và các bean xác thực thành hai configuration class riêng và chỉ import chain, hoặc test các request đó bằng @SpringBootTest.

@DataJpaTest: repository trên database embedded

@DataJpaTest cấu hình những gì

@DataJpaTest là phiên bản JPA của web slice. Bytecode của nó trong 4.1.1 cho thấy nó được meta-annotate bằng @AutoConfigureDataJpa (repository của Spring Data và Hibernate), @AutoConfigureJdbc (DataSource, transaction manager, JdbcTemplate, JdbcClient), @AutoConfigureTestDatabase, @AutoConfigureTestEntityManager@Transactional, cùng một type filter chỉ scan JPA component và attribute showSql mặc định là true, thứ set spring.jpa.show-sql và in ra mọi câu lệnh. Context của catalogue chứa 83 bean definition: không controller, không tầng web, không security, không ProductService.

Test database là phần cần hiểu. Ứng dụng trỏ tới jdbc:h2:mem:catalog, vậy mà log của @DataJpaTest đầu tiên ghi:

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'

Một test hỏi DataSource được inject về URL của nó in ra jdbc:h2:mem:5a433bfe-9186-4cce-a896-61d72556390f. @AutoConfigureTestDatabase khai báo replace với giá trị mặc định Replace.NON_TEST trong 4.1.1, nghĩa là thay DataSource của ứng dụng bằng một database embedded có tên duy nhất, trừ khi DataSource đó đến từ hạ tầng dành cho test như @ServiceConnection. Với @AutoConfigureTestDatabase(replace = Replace.NONE) trên class, cùng câu hỏi đó trả về jdbc:h2:mem:catalog qua HikariCP, tức database đã cấu hình. NONE là cách một slice chạy với PostgreSQL thật; H2 không phải PostgreSQL, và test với engine thật bằng Testcontainers là chủ đề của khóa Advanced.

Mỗi test chạy trong một transaction bị rollback

@DataJpaTest@Transactional, nên spring-test mở một transaction trước mỗi test method và rollback nó sau đó. Hai test có thứ tự chứng minh điều này bằng cách đếm số dòng:

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));
    }
}

Với logging.level.org.springframework.test.context.transaction=debug, transaction quanh mỗi method hiện ra:

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]

Câu INSERT thật sự tới H2, test thứ nhất đếm được 1 bên trong transaction của nó, và rollback xóa dòng đó trước khi test thứ hai đếm được 0. Mỗi test bắt đầu từ schema mà Hibernate đã tạo, không phải từ những gì test trước để lại. Muốn giữ thay đổi của một test, đánh dấu method bằng @Commit hoặc @Rollback(false).

TestEntityManager và bẫy first-level cache

TestEntityManager bọc EntityManager của transaction trong test với các helper dành cho việc chuẩn bị dữ liệu: persist, persistAndFlush, persistAndGetId, find, flush, clear. Dùng nó cho phần setup giúp repository method đang được test không lẫn vào bước chuẩn bị.

Nó cũng phơi ra cái bẫy mà sớm hay muộn mọi JPA test đều gặp. Setup và assertion chạy trong cùng một transaction, nên trong cùng một persistence context, tức first-level cache. Test này save một price có ba chữ số thập phân vào cột numeric(10,2):

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"));
    }

pass. SQL log giải thích lý do:

Text
Hibernate: insert into products (category,name,price,sku,id) values (?,?,?,?,default)

Chỉ một câu lệnh. INSERT chạy ở save(), vì key IDENTITY chỉ tồn tại khi dòng đã tồn tại; sau đó findById tìm thấy entity trong persistence context và trả về chính object Java đó mà không cần SELECT. Assertion so sánh object với chính nó và chưa bao giờ nhìn thấy database. Flush rồi clear persistence context trước khi đọc sẽ buộc một lần load thật:

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 đã làm tròn giá trị theo scale của cột, và chỉ câu SELECT mới cho thấy điều đó. flush() gửi SQL đang chờ, mà với entity này thì không còn gì vì INSERT đã chạy; clear() detach mọi entity, và đó là điều khiến findById phải query. Test được giữ lại assert giá trị đã lưu:

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"));
    }

Key IDENTITY cũng quyết định khi nào một unique constraint báo lỗi. Persist product thứ hai có SKU KB-001 ném exception ngay ở persist thứ hai, không phải ở một lần flush sau đó:

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:

Những thay đổi mà Hibernate chỉ ghi lúc flush thì ngược lại. Một test persist một product bằng persistAndFlush rồi gọi setName với 130 ký tự, cho cột varchar(120), đã pass: log có INSERT và không có UPDATE, vì rollback bỏ đi thay đổi chưa được gửi. Với entityManager.flush() sau setter, câu UPDATE được gửi đi và lỗi:

Text
org.hibernate.exception.DataException: could not execute statement [Value too long for column "NAME CHARACTER VARYING(120)": "'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx... (130)"; SQL statement:

Một repository test thay đổi managed entity phải flush, nếu không database không bao giờ kiểm tra thay đổi đó.

Derived query, @Query và bulk update @Modifying

Các test query chuẩn bị dữ liệu bằng TestEntityManager, flush, rồi gọi 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"));
    }

SQL mà mỗi test gửi sau các câu INSERT của nó:

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=?

Query @Modifying chạy được vì transaction của test đang active; bài 27 đã cho thấy TransactionRequiredException mà nó ném khi không có transaction, nên một repository test tự nó không bắt được lỗi đó, còn service test thì bắt được. Câu select … where p1_0.id=? sau UPDATE là clearAutomatically = true đang làm việc: persistence context đã bị clear, nên findById đọc 98.89 từ bảng thay vì trả về giá trị 89.90 cũ trong cache, đúng giá trị cũ mà bài 27 đã minh họa.

@SpringBootTest: toàn bộ ứng dụng

Môi trường MOCK với @AutoConfigureMockMvc

@SpringBootTest tìm class @SpringBootApplication và khởi động ứng dụng với mọi bean: ở đây là 289 bean definition, cả JPA stack, ProductService thật, SecurityConfig, UserSeeder. webEnvironment mặc định của nó là MOCK: một servlet web context không có server. @AutoConfigureMockMvc thêm MockMvcMockMvcTester lên trên, nên test trông giống test của web slice, nhưng bên dưới không có gì bị mock:

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@Import(TestPasswordEncoderConfig.class) được giải thích ở phần cấu hình cho test. @AfterTransaction là một hook của spring-test chạy sau khi transaction của test đã kết thúc, bên ngoài nó. POST đi qua chain, controller, service và repository thật vào H2, còn 404 đến từ ProductService thật khi không tìm thấy gì. Log của test đầu tiên, với org.hibernate.SQL ở mức 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

Mọi dòng đều chạy trên Test worker. MockMvcTester gọi filter chain và DispatcherServlet trên chính thread của test, nên ProductService.create, với @Transactional mặc định là REQUIRED, tham gia vào transaction mà test đã mở, và rollback của test hủy câu INSERT.

RANDOM_PORT và RestTestClient

webEnvironment = RANDOM_PORT khởi động Tomcat embedded trên một port trống, và test trở thành một HTTP client thật. Client nào có sẵn thì cần kiểm tra. Một test RANDOM_PORT không có annotation nào trong hai cái, hỏi cả hai qua ObjectProvider, in ra:

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

Spring Boot 4.1.1 không tự đăng ký cái nào. RestTestClient là test client đồng bộ của Spring Framework 7 trong spring-test, và @AutoConfigureRestTestClient từ spring-boot-resttestclient gắn một client vào port ngẫu nhiên; webmvc test starter đã mang module đó theo. @AutoConfigureTestRestTemplate có trong cùng module, nhưng trong project này context không khởi động được với java.lang.NoClassDefFoundError: org/springframework/boot/restclient/RestTemplateBuilder: TestRestTemplate cần module spring-boot-restclient, thứ mà không starter nào được tạo ra có. WebTestClient cần Spring WebFlux trên classpath. Với một project Spring MVC dùng các starter này, RestTestClient là client chạy được ngay.

Test end-to-end: đăng nhập, tạo, đọc lại

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());
    }
}

Các URI là tương đối: client mà Boot cấu hình đã có base URL của server. Đây là test duy nhất trong bài không giả lập thứ gì: AuthenticationManager kiểm tra BCrypt hash từ bảng users, TokenService ký một token RS256 thật bằng private key, và JwtDecoder xác minh nó bằng public key ở request tiếp theo. Test pass, với server ghi Tomcat started on port 56861 (http) with context path '/'.

@Transactional không rollback test RANDOM_PORT

Class có @Transactional, giống test MOCK. Log kể một câu chuyện khác:

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

Transaction của test bắt đầu và rollback trên Test worker, nhưng INSERT chạy trên o-auto-1-exec-2, một request thread của Tomcat. Một transaction của Spring gắn với thread đã mở nó, nên ProductService.create trên thread của server không tìm thấy transaction nào để tham gia, tự mở transaction riêng và commit trước khi trả 201. Rollback trên thread của test không có gì của request để hủy, và product ở lại: một dòng sau test. Test tiếp theo dùng chung database đó sẽ thấy nó.

@Transactional trên một test RANDOM_PORT chỉ tạo cảm giác cô lập giả. Bỏ nó đi và dọn những gì server đã commit:

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() chạy trong transaction riêng và commit. Với bộ dữ liệu lớn hơn, một script @Sql sau mỗi method hoặc một database mới cho mỗi test class làm cùng việc đó.

Cấu hình cho test: @TestConfiguration và @ActiveProfiles

@TestConfiguration cho bean chỉ dùng trong test

Việc login trong test end-to-end hash một password, và UserSeeder hash hai password lúc khởi động, tất cả với cost mặc định 10 của BCrypt. Test không cần độ mạnh đó. Một @TestConfiguration thêm một encoder rẻ hơn:

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 là một @Configuration mà component scan bỏ qua, nên nó chỉ có hiệu lực ở nơi một test import nó bằng @Import, hoặc khi nó là static nested class của test.
  • @Primary làm nó thắng SecurityConfig.passwordEncoder ở mọi chỗ inject một PasswordEncoder. Với import này, encoder được inject vào test và hash mà UserSeeder lưu cho admin đều bắt đầu bằng $2a$04$, tức cost 4.

Bean có tên riêng là có chủ ý. Nếu đặt tên passwordEncoder, trùng với bean của ứng dụng, context không khởi động được:

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 tắt bean definition overriding, trong test cũng vậy. Hãy đặt cho bean của test một tên khác kèm @Primary, hoặc thay bean một cách có chủ đích bằng @MockitoBean hay @TestBean, vốn được tạo ra cho việc đó.

@ActiveProfiles("test") và application-test.properties

@ActiveProfiles("test") bật profile test cho context của test, và Boot khi đó load thêm application-test.properties đè lên application.properties, đúng như bài 13 mô tả cho một ứng dụng đang chạy:

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

Chính dòng đó đã đưa các câu lệnh org.hibernate.SQL, kèm tên thread, vào log của @SpringBootTest ở trên. Hãy để file dưới src/test/resources với hậu tố profile. Một file src/test/resources/application.properties sẽ đứng trước file chính trên test classpath, và file chính sẽ không còn được load cho test: trong một lần thử, một @DataJpaTest với file như vậy thấy spring.application.name, spring.datasource.urlapp.jwt.private-key-location đều là null.

Test context cache: test nào dùng chung context

Bật log của context cache

spring-test giữ mọi context nó dựng trong một cache static suốt vòng đời của JVM, với key là cấu hình đã merge của test class: annotation, configuration class, các @Import, profile đang bật, property, tập các field @MockitoBean và nhiều thứ khác. Một test class có cùng key dùng lại context; key khác thì dựng context mới. Logging category là org.springframework.test.context.cache. Ở mức debug, spring-test 7.0.9 ghi thống kê cache sau mỗi lần tra; ở mức trace nó thêm Storing ApplicationContext [...] in cache under key [...]Retrieved ApplicationContext [...] from cache with key [...], với key là toàn bộ cấu hình đã merge. Gradle truyền nó cho test JVM dưới dạng system property, thứ mà Spring Boot đọc như mọi property khác:

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

Thứ tự chạy được cố định bằng class orderer của JUnit:

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

Test class nào dùng lại context

Bộ test là năm class của bài này. ./gradlew test --rerun ghi output của mỗi class vào XML report của nó dưới build/test-results/test, hoặc ra console với showStandardStreams = true của bài 37. Lần chạy nhanh nhất trong ba lần, ở load average 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

Dòng thống kê cuối cùng của lần chạy:

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: bốn cấu hình, bốn context được dựng và giữ lại.
  • ProductControllerTest dùng lại context mà ProductControllerMockMvcTest đã dựng: cùng @WebMvcTest(ProductController.class), cùng @Import, cùng @MockitoBean ProductService.
  • hitCount = 276 đếm mọi lần tra, và spring-test tra context nhiều lần cho mỗi test method, nên đó không phải số class dùng chung.
  • Context đầu tiên mất 1745 ms còn các context khác từ 121 đến 299 ms. Context đầu tiên trả giá cho việc load các class của Spring, Hibernate và Tomcat vào một JVM còn lạnh; chi phí đó chỉ trả một lần cho mỗi JVM, bất kể test là gì.
  • MOCKRANDOM_PORT không bao giờ dùng chung: webEnvironment là một phần của key.

Năm test class chạy lần lượt; các cấu hình giống nhau dùng lại một context trong cache, còn một @SpringBootTest có thêm @MockitoBean và một @DirtiesContext mỗi thứ buộc dựng thêm một context, kèm thời gian load từ các lần chạy

Một bộ @MockitoBean khác hoặc @DirtiesContext buộc tạo context mới

Test 500 ban đầu nằm trong một class riêng, trên toàn bộ ứng dụng:

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.");
    }
}

Annotation của nó khớp hoàn toàn với ProductApiMockEnvironmentTest, nhưng field @MockitoBean làm đổi key. Thêm vào bộ test, nó dựng application context 289 bean của riêng nó và cache kết thúc với size = 5, missCount = 5. Biến thể thứ hai đánh dấu ProductControllerMockMvcTest bằng @DirtiesContext, thứ đóng context của nó và gỡ khỏi cache sau class; ProductControllerTest, class kế tiếp, khi đó phải dựng lại đúng web slice đó. Ba lần chạy cho mỗi bộ test, mỗi lần ở load average từ 6.4 đến 6.8:

Bộ testSố context được dựngThời gian bộ test, tốt nhất trong 3Context thêm vào
Năm class43.24 s
+ ProductApiFailureTest với @MockitoBean53.20 smột application context đầy đủ thứ hai: 198, 264 và 275 ms
+ @DirtiesContext trên ProductControllerMockMvcTest53.51 slại web slice cho ProductControllerTest: 143, 159 và 176 ms

Thời gian bộ test, cộng từ các JUnit report, dao động trong chính biên độ khoảng nửa giây giữa các lần chạy, và biến thể có thêm context thậm chí có lần chạy tốt nhất nhanh nhất. Thứ mỗi biến thể thêm vào thì đo được trong trace: thêm một context, khoảng một phần tư giây cho toàn bộ ứng dụng và một phần sáu giây cho web slice trong một JVM đã nóng. Với catalogue cỡ này, đó là nhiễu. Nhưng đó là chi phí cố định cho mỗi cấu hình khác nhau, và nó tăng theo ứng dụng: nhiều entity hơn cho Hibernate map, migration Flyway phải chạy, một Tomcat cho mỗi context RANDOM_PORT, và mọi context trong cache được giữ trong bộ nhớ, cùng connection pool của nó, cho tới khi JVM thoát hoặc cache, giới hạn ở maxSize = 32, loại nó ra. Những thói quen giữ số context nhỏ thì không tốn gì: test việc map lỗi trong web slice, nơi service vốn đã bị mock, thay vì dùng @MockitoBean trên một @SpringBootTest; cho các integration test một bộ annotation chung, chẳng hạn trên một base class; và chỉ dùng @DirtiesContext khi một test thật sự làm context không dùng được nữa.

Chọn test annotation nào

AnnotationLoad gì với project nàyBeanKhởi động, JVM mới (load)Dùng cho
không có (bài 37)không gì cả: new ProductService(mock)0không có contextrule bên trong một class
@WebMvcTest(ProductController.class)MVC, Jackson, validation, advice, một controller, auto-configuration của security; chain của bạn chỉ khi @Import1750.93 s (6.96)request mapping, binding, validation, status code, body ProblemDetail, rule security theo URL
@DataJpaTestDataSource bị thay bằng H2 embedded, Hibernate, repository, TestEntityManager, rollback sau mỗi test831.34 s (7.80)derived query, @Query, @Modifying, mapping và constraint
@SpringBootTest + @AutoConfigureMockMvcmọi thứ, không server, request chạy trên thread của test2891.71 s (6.87)các tầng chạy cùng nhau; rollback bằng @Transactional hoạt động
@SpringBootTest(webEnvironment = RANDOM_PORT) + @AutoConfigureRestTestClientmọi thứ cùng Tomcat trên port ngẫu nhiên2871.93 s (6.96)HTTP thật từ đầu đến cuối; dọn những gì server commit

Vài công cụ lân cận nằm ngoài bài này. Testcontainers với @ServiceConnection, thứ chạy slice hoặc ứng dụng với PostgreSQL thật, thuộc khóa Advanced, cũng như contract testing, ArchUnit và performance testing. Một RestClient gọi service khác, như ở bài 23, được test bằng MockRestServiceServer của spring-test thay cho một remote server thật. View Thymeleaf, như ở bài 24, được test bằng chính @WebMvcTestMockMvcTester, assert hasViewName và model thay cho JSON.

FAQ

Cái gì thay thế @MockBean trong Spring Boot 4?

@MockitoBean từ org.springframework.test.context.bean.override.mockito trong spring-test, và @MockitoSpyBean cho @SpyBean. @MockBean bị deprecate từ Spring Boot 3.4 và không jar nào của Spring Boot 4.1.1 chứa nó, nên import cũ lỗi với package org.springframework.boot.test.mock.mockito does not exist. Một field @MockitoBean thay bean cùng type trong context của test, hoặc đăng ký mới khi chưa có, và được reset sau mỗi test method.

Vì sao @WebMvcTest bỏ qua SecurityFilterChain của tôi?

Vì một class @Configuration không phải web component, và component scan của slice chỉ lấy controller, advice, converter, filter và các type tương tự. Slice vẫn có auto-configuration của Spring Security, nên nó chạy chain mặc định của Boot: khi có cấu hình resource server, mọi request cần xác thực và CSRF bật, khiến một GET công khai thành 401 và một POST thành 403 với body rỗng. Thêm @Import với security configuration class của bạn và mọi bean mà filter chain của nó phụ thuộc.

Vì sao package của @WebMvcTest và @DataJpaTest đổi trong Spring Boot 4?

Spring Boot 4 tách auto-configuration và hỗ trợ test thành các module theo từng công nghệ. @WebMvcTest@AutoConfigureMockMvc giờ nằm trong org.springframework.boot.webmvc.test.autoconfigure (spring-boot-webmvc-test), @DataJpaTest trong org.springframework.boot.data.jpa.test.autoconfigure, @AutoConfigureTestDatabase trong org.springframework.boot.jdbc.test.autoconfigureTestEntityManager trong org.springframework.boot.jpa.test.autoconfigure. Các test starter theo feature mà Spring Initializr tạo ra mang theo đúng các module đó.

@DataJpaTest có dùng database đã cấu hình không?

Mặc định là không. @AutoConfigureTestDatabase mặc định replace = Replace.NON_TEST, và nó đã thay jdbc:h2:mem:catalog bằng một database H2 embedded có tên là một UUID ngẫu nhiên. Thêm @AutoConfigureTestDatabase(replace = Replace.NONE) để dùng DataSource đã cấu hình, đó là điều một slice test chạy với database server thật cần.

Vì sao @Transactional không rollback @SpringBootTest với RANDOM_PORT?

Request được xử lý trên một thread của Tomcat, và một transaction của Spring thuộc về thread đã bắt đầu nó. Service method trên server tự mở và commit transaction của riêng nó; transaction của test trên thread Test worker không rollback được gì trong đó, và product tạo trong test vẫn còn trong bảng sau đó. Trong môi trường MOCK mặc định, request chạy trên thread của test, tham gia transaction của test và bị rollback. Với RANDOM_PORT, hãy xóa dữ liệu test một cách tường minh.

Nên dùng TestRestTemplate hay RestTestClient trong Spring Boot 4?

Trong một project Spring MVC tạo với webmvc test starter, RestTestClient với @AutoConfigureRestTestClient chạy được mà không cần thêm dependency. TestRestTemplate đã chuyển sang org.springframework.boot.resttestclient, cần @AutoConfigureTestRestTemplate, và trong project này lỗi với NoClassDefFoundError: org/springframework/boot/restclient/RestTemplateBuilder cho tới khi thêm module spring-boot-restclient. Không client nào được đăng ký cho test RANDOM_PORT nếu thiếu annotation của nó.

Kết luận

Test trong Spring Boot là chọn khởi động bao nhiêu phần của ứng dụng. @WebMvcTest khởi động tầng web: controller, advice, Jackson và validation, với @MockitoBean thay cho service và MockMvcTester để gửi request và assert status, header và JSON qua AssertJ. Security trong slice đó là mặc định của Spring Boot cho tới khi chain của ứng dụng được import cùng các bean nó cần, và khi đó @WithMockUser hoặc jwt() quyết định ai đang gọi. @DataJpaTest khởi động JPA trên một database embedded, bọc mỗi test trong một transaction bị rollback, và đòi một flush()clear() trước mọi assertion cần nhìn thấy bảng thay vì first-level cache. @SpringBootTest khởi động mọi thứ: trên thread của test với MOCK, nơi @Transactional rollback được, hoặc sau một Tomcat thật với RANDOM_PORTRestTestClient, nơi nó không rollback được.

Boot 4 chuyển các annotation vào module theo feature và thay @MockBean bằng @MockitoBean, còn context cache quyết định chi phí của một bộ test: mỗi tổ hợp khác nhau của annotation, import, profile và mock là thêm một context phải dựng và giữ.

Bài này khép lại Chương 6. Chương 7 nói về các công cụ giúp công việc hằng ngày nhanh hơn, và bài 39 mở đầu chương với Spring Boot DevTools, Lombok cùng ưu nhược điểm của nó, và Actuator cơ bản với /health/info.

Bài viết liên quan

[Spring Boot Basics] Auditing với Spring Data JPA: @CreatedDate, @LastModifiedDate và @CreatedBy

Auditing với Spring Data JPA trên Spring Boot 4.1.1 và PostgreSQL: @EnableJpaAuditing, AuditingEntityListener và một base class @MappedSuperclass, Flyway migration thêm column audit NOT NULL vào table đã có dữ liệu, giá trị null âm thầm khi thiếu annotation hoặc listener, Instant so với LocalDateTime và OffsetDateTime cùng giá trị timestamptz thực sự lưu, @LastModifiedDate đổi khi nào và modifyOnCreate thay đổi gì, save() một detached entity ghi null vào created_at và @Column(updatable = false), @CreatedBy lấy từ header X-User qua AuditorAware, DateTimeProvider dựa trên Clock, bulk update và native update bỏ qua auditing, và so sánh với @CreationTimestamp, @UpdateTimestamp của Hibernate.

[Spring Boot Basics] Tài liệu API trong Spring Boot với springdoc-openapi và Swagger UI

springdoc-openapi trên Spring Boot 4.1.1: document OpenAPI 3.1 ở /v3/api-docs, Swagger UI và Try it out, những gì springdoc suy ra từ controller, DTO record và Bean Validation constraint, response nào của @RestControllerAdvice được thêm vào, @Tag, @Operation, @ApiResponse, @Parameter và @Schema trên record, bean OpenAPI và customizer toàn cục, GroupedOpenApi, property của springdoc và tắt tài liệu trong profile prod.

[Spring Boot Basics] Unit test trong Spring Boot: JUnit 6, AssertJ và Mockito cho tầng Service

Unit test cho tầng service của ứng dụng Spring Boot 4.1.1 với JUnit, AssertJ và Mockito: unit test thay thế những gì, test task của Gradle và report, mỗi test method một instance mới được chứng minh bằng identity, @Nested và tên hiển thị của parameterized test trong JUnit 6, bẫy isEqualTo với BigDecimal và soft assertion cùng thông báo lỗi, @Mock với constructor injection so với @InjectMocks truyền null, stub, verify và ArgumentCaptor, UnnecessaryStubbingException và PotentialStubbingProblem dưới strict stubs, một Clock cố định, và nạp Mockito dưới dạng -javaagent để bỏ cảnh báo self-attaching.

[Spring Boot Basics] Xử lý exception tập trung trong Spring Boot: @RestControllerAdvice, @ExceptionHandler và ProblemDetail

Xử lý exception tập trung trong Spring Boot 4.1.1: body /error mặc định và BasicErrorController, spring.web.error.* thay cho server.error.*, @ResponseStatus và ResponseStatusException, @ExceptionHandler trong controller và trong @RestControllerAdvice, cách Spring chọn một handler theo khoảng cách type, controller, @Order và cause, ProblemDetail (RFC 9457) với application/problem+json, ErrorResponseException, spring.mvc.problemdetails.enabled, ResponseEntityExceptionHandler trả 422 kèm danh sách lỗi theo field, và một handler catch-all giữ nguyên các response 4xx của framework.