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-jpa và h2. 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.
![]()
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.
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.javaEntity map bảng products với key IDENTITY, sku unique và price kiểu numeric(10,2):
@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:
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:
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
public List<Product> findAll() {
return repository.findAll(Sort.by("id"));
}
public Product findById(long id) {
return repository.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
}
@Transactional
public Product create(Product product) {
if (repository.existsBySku(product.getSku())) {
throw new DuplicateSkuException(product.getSku());
}
return repository.save(product);
}
}@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping
public List<ProductResponse> findAll() {
return service.findAll().stream().map(ProductResponse::from).toList();
}
@GetMapping("/{id}")
public ProductResponse findById(@PathVariable long id) {
return ProductResponse.from(service.findById(id));
}
@PostMapping
public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
Product product = service.create(request.toProduct());
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(product.getId())
.toUri();
return ResponseEntity.created(location).body(ProductResponse.from(product));
}
}CreateProductRequest là một record với @NotBlank trên sku, name và category, @NotNull @Positive trên price; ProductResponse chứa id, sku, name, category và price. 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:
@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 USER và admin với role ADMIN. Cấu hình:
spring.application.name=demo
spring.datasource.url=jdbc:h2:mem:catalog
spring.jpa.open-in-view=false
spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:certs/public.pem
spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles
spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_
app.jwt.private-key-location=classpath:certs/private.pemTest 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:
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 starter | Mang theo | Cho 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-test | spring-boot-webmvc-test, spring-boot-resttestclient | @WebMvcTest, @AutoConfigureMockMvc, @AutoConfigureRestTestClient |
spring-boot-starter-data-jpa-test | spring-boot-data-jpa-test, spring-boot-jpa-test, spring-boot-jdbc-test | @DataJpaTest, TestEntityManager, @AutoConfigureTestDatabase |
spring-boot-starter-security-test | spring-boot-security-test, spring-security-test 7.1.1 | @WithMockUser, jwt(), security 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:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;./gradlew compileTestJava> Task :compileTestJava FAILED
src/test/java/com/example/demo/product/ProductControllerTest.java:5: error: package org.springframework.boot.test.autoconfigure.web.servlet does not exist
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
^
src/test/java/com/example/demo/product/ProductControllerTest.java:6: error: package org.springframework.boot.test.mock.mockito does not exist
import org.springframework.boot.test.mock.mockito.MockBean;
^
src/test/java/com/example/demo/product/ProductRepositoryTest.java:5: error: package org.springframework.boot.test.autoconfigure.orm.jpa does not exist
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
^
src/test/java/com/example/demo/product/ProductRepositoryTest.java:7: error: cannot find symbol
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
^
symbol: class AutoConfigureTestDatabase
location: package org.springframework.boot.test.autoconfigure.jdbcBuild 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:
| Type | Import của Spring Boot 3 | Import của Spring Boot 4.1.1 | Jar |
|---|---|---|---|
@WebMvcTest | org.springframework.boot.test.autoconfigure.web.servlet | org.springframework.boot.webmvc.test.autoconfigure | spring-boot-webmvc-test |
@AutoConfigureMockMvc | org.springframework.boot.test.autoconfigure.web.servlet | org.springframework.boot.webmvc.test.autoconfigure | spring-boot-webmvc-test |
@DataJpaTest | org.springframework.boot.test.autoconfigure.orm.jpa | org.springframework.boot.data.jpa.test.autoconfigure | spring-boot-data-jpa-test |
@AutoConfigureTestDatabase | org.springframework.boot.test.autoconfigure.jdbc | org.springframework.boot.jdbc.test.autoconfigure | spring-boot-jdbc-test |
TestEntityManager | org.springframework.boot.test.autoconfigure.orm.jpa | org.springframework.boot.jpa.test.autoconfigure | spring-boot-jpa-test |
@MockBean | org.springframework.boot.test.mock.mockito.MockBean | @MockitoBean trong org.springframework.test.context.bean.override.mockito | spring-test |
TestRestTemplate | org.springframework.boot.test.web.client | org.springframework.boot.resttestclient | spring-boot-resttestclient |
@SpringBootTest | org.springframework.boot.test.context | không đổi | spring-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:
package com.example.demo;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
import org.springframework.context.ApplicationContext;
import org.springframework.core.Ordered;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.TestExecutionListener;
public class ContextStatsListener implements TestExecutionListener, Ordered {
private static final Set<ApplicationContext> seen = Collections.newSetFromMap(new IdentityHashMap<>());
@Override
public void beforeTestClass(TestContext testContext) {
long start = System.nanoTime();
ApplicationContext context = testContext.getApplicationContext();
long millis = (System.nanoTime() - start) / 1_000_000;
boolean reused = !seen.add(context);
System.out.printf("[context] %s: %s, %d bean definitions%n",
testContext.getTestClass().getSimpleName(),
reused ? "reused from cache" : "loaded in " + millis + " ms",
context.getBeanDefinitionCount());
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}org.springframework.test.context.TestExecutionListener=com.example.demo.ContextStatsListenerMỗ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 class | Annotation | Bean definition | Started … in (tốt nhất trong 3) | Listener | Load average |
|---|---|---|---|---|---|
ProductControllerTest | @WebMvcTest(ProductController.class) + @Import security | 175 | 0.932 s | 943 ms | 6.96 |
ProductRepositoryTest | @DataJpaTest | 83 | 1.335 s | 1349 ms | 7.80 |
ProductApiMockEnvironmentTest | @SpringBootTest + @AutoConfigureMockMvc | 289 | 1.707 s | 1803 ms | 6.87 |
ProductApiRandomPortTest | @SpringBootTest(webEnvironment = RANDOM_PORT) + @AutoConfigureRestTestClient | 287 | 1.931 s | 2025 ms | 6.96 |
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 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 và @Configuration bị bỏ ra ngoài.
Test đầu tiên chỉ yêu cầu một product:
package com.example.demo.product;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
MockMvcTester mvc;
@Test
void returnsProductById() {
assertThat(mvc.get().uri("/api/products/1")).hasStatusOk();
}
}./gradlew testVới test logging mặc định của Gradle, console chỉ in chuỗi exception:
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:2304Test report chứa failure analysis của Spring Boot:
***************************
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ó:
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, mockMvcTester và jwtDecoderByPublicKeyValue. 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.

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):
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()vàhasContentType(...)kiểm tra chính response.bodyJson()chuyển sang assertion JSON trên body.isLenientlyEqualToso sánh bằng JSONassert ở chế độ lenient: các field được liệt kê phải khớp,categorycó 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.setFieldgá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:
@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:
@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:
@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:
@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:
@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:
@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 đó:
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:
org.opentest4j.AssertionFailedError: [HTTP status code]
expected: 201
but was: 200còn MockMvc:
java.lang.AssertionError: Status expected:<201> but was:<200>Khi chờ KB-002 ở $.sku, MockMvcTester báo:
org.opentest4j.AssertionFailedError:
expected: "KB-002"
but was: "KB-001"còn MockMvc:
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,contenthayjsonPathtừ ba class builder và matcher; request bắt đầu từmvcvà các phép kiểm tra là AssertJ bình thường. - Không
throws Exception.performkhai báo nó;MockMvcTesterthì không. - Giá trị có type.
extractingPath(...).asNumber(),asArray().containsExactly(...)vàconvertTo(ProductResponse.class)đưa cả API AssertJ tới một giá trị, trong khijsonPath(...).value(...)chỉ kiểm tra một giá trị mong đợi. - So sánh cả document.
isLenientlyEqualTokiể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:
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:
| Request | Status | WWW-Authenticate | Body |
|---|---|---|---|
GET /api/products/1 | 401 | Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource" | rỗng |
POST /api/products | 403 | không có | rỗng |
POST /api/products kèm csrf() của spring-security-test | 401 | Bearer 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:
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:
@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:
@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 cho | test method hoặc test class | một request |
Authentication | UsernamePasswordAuthenticationToken | JwtAuthenticationToken |
| Principal | một User tên user | mộ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, JwtDecoder | không liên quan | khô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-name và authority-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:
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() và @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 và @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:
[ 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 là @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:
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:
[ 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):
@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"));
}Nó pass. SQL log giải thích lý do:
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:
@Test
void priceIsStoredAsSavedAfterFlushAndClear() {
Product saved = repository.save(new Product("KB-001", "Keyboard KB-001", "keyboards", new BigDecimal("89.999")));
entityManager.flush();
entityManager.clear();
Product found = repository.findById(saved.getId()).orElseThrow();
assertThat(found.getPrice()).isEqualTo(new BigDecimal("89.999"));
}Hibernate: insert into products (category,name,price,sku,id) values (?,?,?,?,default)
Hibernate: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?org.opentest4j.AssertionFailedError:
expected: 89.999
but was: 90.00H2 đã 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:
@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 đó:
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:
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:
@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ó:
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 MockMvc và MockMvcTester 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:
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 và @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:
[ 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: 0Mọ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:
>>> without annotations: RestTestClient=null TestRestTemplate=nullSpring 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
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:
[ 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: 1Transaction 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:
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:
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);
}
}@TestConfigurationlà một@Configurationmà 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.@Primarylàm nó thắngSecurityConfig.passwordEncoderở mọi chỗ inject mộtPasswordEncoder. Với import này, encoder được inject vào test và hash màUserSeederlưu choadminđề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:
Description:
The bean 'passwordEncoder', defined in com.example.demo.probe.ClashProbeTest$ClashConfig, could not be registered. A bean with that name has already been defined in class path resource [com/example/demo/common/SecurityConfig.class] and overriding is disabled.
Action:
Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=trueSpring Boot 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:
logging.level.org.hibernate.SQL=debugChí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.url và app.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 [...] và 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:
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:
junit.jupiter.testclass.order.default=org.junit.jupiter.api.ClassOrderer$ClassNameTest 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:
[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 definitionsDòng thống kê cuối cùng của lần chạy:
[ 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.ProductControllerTestdù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ì.
MOCKvàRANDOM_PORTkhông bao giờ dùng chung:webEnvironmentlà một phần của key.

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:
@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ộ test | Số context được dựng | Thời gian bộ test, tốt nhất trong 3 | Context thêm vào |
|---|---|---|---|
| Năm class | 4 | 3.24 s | — |
+ ProductApiFailureTest với @MockitoBean | 5 | 3.20 s | một application context đầy đủ thứ hai: 198, 264 và 275 ms |
+ @DirtiesContext trên ProductControllerMockMvcTest | 5 | 3.51 s | lạ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
| Annotation | Load gì với project này | Bean | Khởi động, JVM mới (load) | Dùng cho |
|---|---|---|---|---|
| không có (bài 37) | không gì cả: new ProductService(mock) | 0 | không có context | rule 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 @Import | 175 | 0.93 s (6.96) | request mapping, binding, validation, status code, body ProblemDetail, rule security theo URL |
@DataJpaTest | DataSource bị thay bằng H2 embedded, Hibernate, repository, TestEntityManager, rollback sau mỗi test | 83 | 1.34 s (7.80) | derived query, @Query, @Modifying, mapping và constraint |
@SpringBootTest + @AutoConfigureMockMvc | mọi thứ, không server, request chạy trên thread của test | 289 | 1.71 s (6.87) | các tầng chạy cùng nhau; rollback bằng @Transactional hoạt động |
@SpringBootTest(webEnvironment = RANDOM_PORT) + @AutoConfigureRestTestClient | mọi thứ cùng Tomcat trên port ngẫu nhiên | 287 | 1.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 @WebMvcTest và MockMvcTester, 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 và @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.autoconfigure và TestEntityManager 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() và 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_PORT và RestTestClient, 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 và /info.