Chương 3 đến 5 đã dựng một API catalogue và order với những rule quan trọng: SKU phải duy nhất, stock không bao giờ xuống dưới 0, một order lấy stock cho từng dòng nó chứa. Cho tới giờ các rule đó được kiểm tra bằng curl vào một ứng dụng đang chạy với database thật. Bài này mở đầu Chương 6 bằng cách kiểm tra chúng với unit test: class service chạy thật, các repository của nó được thay bằng mock của Mockito, và không có gì phải khởi động, cả Spring lẫn database.
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, data-jpa và h2, cùng các thư viện test do Boot quản lý: JUnit 6, AssertJ và Mockito 5.
![]()
Hai phần đầu dựng code và test task của Gradle; JUnit, AssertJ và Mockito mỗi thứ có một phần riêng, và các phần cuối ghép chúng lại trên ProductService và OrderService.
Unit test là gì trong một ứng dụng Spring Boot?
Unit test ở đây là một class đang được test, tạo bằng new, với mỗi collaborator mà nó dùng tới được thay bằng thứ mà test kiểm soát: một mock của Mockito cho repository, một Clock cố định cho thời gian. Không có ApplicationContext, không có proxy @Transactional, không Hibernate và không database, nên test đo đúng một điều: code trong class đó có làm đúng những gì rule của nó nói hay không.
Tầng service là nơi điều đó đáng giá nhất. Bài 21 đặt các business rule ở đây: controller chuyển đổi HTTP, repository lưu trữ, còn service quyết định rằng SKU trùng là lỗi, hay một order thiếu stock không được save. Một unit test cho service có thể đi qua mọi nhánh của các rule đó, kể cả những nhánh lỗi khó tạo ra qua HTTP, chỉ trong vài mili giây.

Điều unit test không nói được là các mảnh có khớp với nhau hay không: derived query trong ProductRepository có hợp lệ không, JSON của response có đúng không, transaction có rollback không. Bài tiếp theo thêm các test đó lên trên bài này, với Spring context: @SpringBootTest, @WebMvcTest và @DataJpaTest. Testcontainers, rule kiến trúc, contract test và performance test thuộc về khóa Advanced.
Các class được test
Code là catalogue của Chương 4, cắt gọn còn những gì test cần: không có customer, không có category. Nó giữ layout package-by-feature từ bài 21. Product là một JPA entity, và giờ tự giữ rule về stock, để OrderService dùng thẳng ProductRepository mà không cần một bản sao thứ hai của phép kiểm tra:
package com.example.demo.product;
import java.math.BigDecimal;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String sku;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal price;
@Column(nullable = false)
private int stock;
protected Product() {
}
public Product(String name, String sku, BigDecimal price, int stock) {
this.name = name;
this.sku = sku;
this.price = price;
this.stock = stock;
}
public void decreaseStock(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive, was " + quantity);
}
if (quantity > stock) {
throw new InsufficientStockException(sku, stock, quantity);
}
stock -= quantity;
}
// getters for id, name, sku, price and stock
}package com.example.demo.product;
public class InsufficientStockException extends RuntimeException {
public InsufficientStockException(String sku, int available, int requested) {
super("Only " + available + " of " + sku + " in stock, " + requested + " requested");
}
}DuplicateSkuException và ProductNotFoundException có cùng hình dạng, với message SKU KB-01 already exists và Product 99 not found. Cả ba đều là unchecked exception, và GlobalExceptionHandler từ các chương trước map chúng thành 409, 409 và 404.
package com.example.demo.product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
boolean existsBySku(String sku);
}package com.example.demo.product;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository products;
public ProductService(ProductRepository products) {
this.products = products;
}
public Product findById(Long id) {
return products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
}
@Transactional
public Product create(Product product) {
if (products.existsBySku(product.getSku())) {
throw new DuplicateSkuException(product.getSku());
}
return products.save(product);
}
}Phép kiểm tra existsBySku cho ra một 409 rõ ràng trong trường hợp thường gặp; constraint unique trên cột vẫn bắt được hai request cùng qua phép kiểm tra ở cùng một thời điểm. Phía order có một entity với các dòng, một record cho request và một repository:
package com.example.demo.order;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Instant placedAt;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderLine> lines = new ArrayList<>();
protected Order() {
}
public Order(Instant placedAt) {
this.placedAt = placedAt;
}
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}
public BigDecimal total() {
return lines.stream()
.map(OrderLine::lineTotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
// getters for id, placedAt and lines
}package com.example.demo.order;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "order_lines")
public class OrderLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne
@JoinColumn(name = "product_id")
private Product product;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal unitPrice;
protected OrderLine() {
}
public OrderLine(Product product, int quantity) {
this.product = product;
this.quantity = quantity;
this.unitPrice = product.getPrice();
}
public BigDecimal lineTotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
void setOrder(Order order) {
this.order = order;
}
// getters for product, quantity and unitPrice
}package com.example.demo.order;
public record OrderItem(Long productId, int quantity) {
}package com.example.demo.order;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<Order, Long> {
}OrderService.placeOrder gắn thời gian cho order từ một Clock được inject, lấy stock cho từng dòng và save order một lần. Bài 32 khai báo một Clock bean trong AuditingConfig cho auditing; project cắt gọn này không có auditing, nên một ClockConfig nhỏ khai báo bean đó (trong project đầy đủ, hãy inject bean từ AuditingConfig):
package com.example.demo.order;
import java.time.Clock;
import java.util.List;
import com.example.demo.product.Product;
import com.example.demo.product.ProductNotFoundException;
import com.example.demo.product.ProductRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final ProductRepository products;
private final OrderRepository orders;
private final Clock clock;
public OrderService(ProductRepository products, OrderRepository orders, Clock clock) {
this.products = products;
this.orders = orders;
this.clock = clock;
}
@Transactional
public Order placeOrder(List<OrderItem> items) {
Order order = new Order(clock.instant());
for (OrderItem item : items) {
Product product = products.findById(item.productId())
.orElseThrow(() -> new ProductNotFoundException(item.productId()));
product.decreaseStock(item.quantity());
order.addLine(new OrderLine(product, item.quantity()));
}
return orders.save(order);
}
}package com.example.demo.common;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ClockConfig {
@Bean
Clock clock() {
return Clock.systemUTC();
}
}Các annotation @Service và @Transactional vẫn nằm trên class. Trong unit test chúng không làm gì cả, vì không có gì đọc chúng: test gọi new OrderService(...), và chỉ Spring container mới bọc object đó trong một transaction proxy.
Chạy test với Gradle
Spring Initializr đặt gì vào build.gradle
Initializr thêm một test starter tương ứng cho mỗi starter data-jpa, validation và webmvc, cộng thêm JUnit Platform launcher, và cấu hình task test chạy trên JUnit Platform:
dependencies {
implementation 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
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-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}useJUnitPlatform() bảo Gradle tìm và chạy test qua JUnit Platform, đó là cách test JUnit 6 được tìm thấy. Xóa dòng đó đi, Gradle 9.7.1 không tìm thấy gì và làm build fail:
> Task :test FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
> There are test sources present and no filters are applied, but the test task did not discover any tests to execute. This is likely due to a misconfiguration. Please check your test configuration. If this is not a misconfiguration, this error can be disabled by setting the 'failOnNoDiscoveredTests' property to false.junit-platform-launcher không có version, vì dependency management của Boot cung cấp nó. Gradle cần launcher có mặt trên test runtime classpath: khi loại nó khỏi testRuntimeClasspath, task test dừng trước khi chạy bất kỳ test nào với
> Failed to load JUnit Platform. Please ensure that all JUnit Platform dependencies are available on the test's runtime classpath, including the JUnit Platform launcher.Trong project này dòng đó là một sự phòng hờ chứ không phải nguồn duy nhất, vì spring-boot-starter-test 4.1.1 cũng có dependency tới launcher (dòng cuối của output bên dưới); xóa dòng testRuntimeOnly đi, test vẫn chạy. Cả ba starter cũng không nhắc tới JUnit, AssertJ hay Mockito: mỗi starter có dependency tới spring-boot-starter-test, và starter này kéo theo tất cả.
./gradlew -q dependencies --configuration testRuntimeClasspath | grep -E -- '--- (org.springframework.boot:spring-boot-starter-test|org.junit.jupiter:junit-jupiter|org.junit.platform:junit-platform-launcher|org.assertj:assertj-core|org.mockito:mockito-core|org.mockito:mockito-junit-jupiter)(:| ->) ?[0-9.]+$'| +--- org.springframework.boot:spring-boot-starter-test:4.1.1
| | +--- org.assertj:assertj-core:3.27.7
| | +--- org.junit.jupiter:junit-jupiter:6.0.3
| | +--- org.mockito:mockito-core:5.23.0
| | +--- org.mockito:mockito-junit-jupiter:5.23.0
| | \--- org.junit.platform:junit-platform-launcher -> 6.0.3Test class nằm trong src/test/java, cùng package với class nó test, nhờ vậy test chạm được tới các thành phần package-private như OrderLine.setOrder.
Lần chạy pass và lần chạy fail
./gradlew testKhi mọi test trong bài đều pass, output mặc định gần như không nói gì:
> Task :compileJava
> Task :processResources
> Task :classes
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
2026-09-16T14:11:24.974+07:00 INFO 92746 --- [demo] [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2026-09-16T14:11:24.979+07:00 INFO 92746 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2026-09-16T14:11:24.981+07:00 INFO 92746 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
> Task :test
BUILD SUCCESSFUL in 8s
4 actionable tasks: 4 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.htmlKhông test nào được liệt kê. Ba dòng log đến từ DemoApplicationTests, class @SpringBootTest mà Initializr sinh ra: application context của nó được đóng bởi một shutdown hook khi JVM của test thoát. Cảnh báo của JVM do Mockito gây ra và được giải thích gần cuối bài. Chỉ một assertion fail là bức tranh thay đổi:
> Task :test
OrderLineTest > multipliesUnitPriceByQuantity() FAILED
org.opentest4j.AssertionFailedError at OrderLineTest.java:18
2026-09-16T14:12:12.662+07:00 INFO 94022 --- [demo] [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2026-09-16T14:12:12.667+07:00 INFO 94022 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2026-09-16T14:12:12.669+07:00 INFO 94022 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
> Task :test FAILED
16 tests completed, 1 failed, 1 skipped
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///.../demo/build/reports/tests/test/index.htmlBuild fail, và Gradle nêu tên test, loại exception và dòng code, nhưng không có message. Message đầy đủ, standard output và thời gian của từng test nằm trong HTML report ở build/reports/tests/test/index.html, mỗi test class một trang, và trong các file XML dưới build/test-results/test, thứ mà CI server đọc.
In từng test ra console với testLogging
Để thấy kết quả trên console, cấu hình logging của task test:
tasks.named('test') {
useJUnitPlatform()
testLogging {
events 'passed', 'skipped', 'failed'
showStandardStreams = true
exceptionFormat = 'full'
}
}events liệt kê những kết quả nào được in một dòng, showStandardStreams in ra những gì test ghi vào System.out và System.err, còn exceptionFormat = 'full' in message lỗi và stack trace. Cùng test đang fail đó, chạy riêng bằng --tests:
./gradlew test --tests OrderLineTest> Task :test FAILED
OrderLineTest > multipliesUnitPriceByQuantity() FAILED
org.opentest4j.AssertionFailedError:
expected: 10.0
but was: 10.00
at app//com.example.demo.order.OrderLineTest.multipliesUnitPriceByQuantity(OrderLineTest.java:18)
1 test completed, 1 failedGiờ lý do đã hiện trên màn hình. Phần AssertJ giải thích vì sao 10.0 và 10.00 không bằng nhau.
Chạy một class hoặc một method
--tests nhận tên class, tên đầy đủ, một cặp Class.method hoặc một pattern có *, và có thể lặp lại. Cho tới khi Mockito agent được cấu hình ở gần cuối bài, test class đầu tiên dùng Mockito còn in thêm một khối STANDARD_ERROR chứa cảnh báo; các output bên dưới lược khối đó đi. Một method:
./gradlew test --tests 'OrderServiceTest.savesNothingWhenALineHasTooLittleStock'OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSED
BUILD SUCCESSFUL in 1sMọi class có tên kết thúc bằng ServiceTest:
./gradlew test --tests '*ServiceTest'OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSED
ProductServiceTest > findById > throws ProductNotFoundException for an unknown id PASSED
ProductServiceTest > create > rejects a duplicate SKU and never saves PASSED
ProductServiceTest > create > saves a product whose SKU is free PASSED
BUILD SUCCESSFUL in 1sGradle bỏ qua task test khi cả code lẫn test đều không đổi kể từ lần chạy pass gần nhất. ./gradlew test --rerun buộc nó chạy lại.
JUnit 6: những phần cốt lõi
JUnit mà Boot 4.1.1 mang theo là JUnit 6.0.3. Với code viết cho JUnit 5, rất ít thứ thay đổi: các annotation và package bên dưới vẫn là những cái cũ. Những khác biệt mà project này gặp là JUnit 6 cần Java 17 trở lên, các artifact của Platform như junit-platform-launcher giờ mang cùng version với Jupiter (6.0.3 ở trên, trong khi JUnit 5 ghép Jupiter 5.x với Platform 1.x), tên của parameterized test đặt text argument trong dấu ngoặc kép, và các class @Nested chạy theo một thứ tự xác định nhưng cố ý không hiển nhiên, như test method vốn đã vậy.
| Annotation | Công dụng |
|---|---|
@Test | Đánh dấu một test method. Method có thể package-private và phải trả về void |
@DisplayName("...") | Tên dễ đọc cho test class hoặc test method trong report và IDE |
@BeforeEach / @AfterEach | Chạy trước và sau mỗi test method, trên instance của test đó |
@BeforeAll / @AfterAll | Chạy một lần trước và sau toàn bộ test của class; mặc định là static |
@TestInstance(Lifecycle.PER_CLASS) | Dùng chung một instance cho mọi test của class; khi đó @BeforeAll có thể không static |
@Nested | Một inner class gom các test liên quan, có lifecycle method riêng |
@ParameterizedTest | Chạy method một lần cho mỗi bộ argument lấy từ một source annotation |
@ValueSource | Một argument cho mỗi lần chạy, từ một mảng literal |
@CsvSource | Nhiều argument cho mỗi lần chạy, từ các dòng CSV |
@MethodSource | Argument lấy từ một static factory method trả về Stream |
@Disabled("reason") | Bỏ qua test class hoặc test method và ghi lại lý do |
@ExtendWith(...) | Đăng ký một extension, ví dụ MockitoExtension |
Mỗi test method một instance mới
JUnit tạo một instance mới của test class cho mỗi test method. Đó là lý do field được gán trong test này không bao giờ rò sang test kế tiếp, và vì sao @BeforeAll là static: lúc nó chạy, chưa có instance nào. Một class in ra identity của chính nó và một bộ đếm instance cho thấy điều đó:
package com.example.demo.lab;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class LifecycleTest {
private static int instances = 0;
private final int number;
private int counter = 0;
LifecycleTest() {
number = ++instances;
log("constructor");
}
@BeforeAll
static void beforeAll() {
System.out.println("@BeforeAll instances so far: " + instances);
}
@BeforeEach
void beforeEach() {
log("@BeforeEach");
}
@Test
void incrementsTheCounter() {
counter++;
log("test");
}
@Test
void incrementsTheCounterAgain() {
counter++;
log("test");
}
@AfterEach
void afterEach() {
log("@AfterEach");
}
@AfterAll
static void afterAll() {
System.out.println("@AfterAll instances created: " + instances);
}
private void log(String step) {
System.out.println(step + " instance #" + number
+ " @" + Integer.toHexString(System.identityHashCode(this))
+ " counter=" + counter);
}
}./gradlew test --tests LifecycleTestLifecycleTest STANDARD_OUT
@BeforeAll instances so far: 0
constructor instance #1 @590adb41 counter=0
LifecycleTest > incrementsTheCounter() STANDARD_OUT
@BeforeEach instance #1 @590adb41 counter=0
test instance #1 @590adb41 counter=1
@AfterEach instance #1 @590adb41 counter=1
LifecycleTest > incrementsTheCounter() PASSED
LifecycleTest STANDARD_OUT
constructor instance #2 @2daf06fc counter=0
LifecycleTest > incrementsTheCounterAgain() STANDARD_OUT
@BeforeEach instance #2 @2daf06fc counter=0
test instance #2 @2daf06fc counter=1
@AfterEach instance #2 @2daf06fc counter=1
LifecycleTest > incrementsTheCounterAgain() PASSED
LifecycleTest STANDARD_OUT
@AfterAll instances created: 2Hai test, hai lần gọi constructor, hai identity, và counter=1 ở cả hai test dù mỗi test đều tăng cùng một field: test thứ hai nhận một object mới, field của nó bắt đầu lại từ 0.

@TestInstance(PER_CLASS) tắt hành vi đó:
package com.example.demo.lab;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PerClassLifecycleTest {
private int counter = 0;
@BeforeAll
void beforeAll() {
System.out.println("@BeforeAll @" + Integer.toHexString(System.identityHashCode(this)));
}
@Test
void incrementsTheCounter() {
counter++;
System.out.println("test @" + Integer.toHexString(System.identityHashCode(this)) + " counter=" + counter);
}
@Test
void incrementsTheCounterAgain() {
counter++;
System.out.println("test @" + Integer.toHexString(System.identityHashCode(this)) + " counter=" + counter);
}
}PerClassLifecycleTest STANDARD_OUT
@BeforeAll @f1f7db2
PerClassLifecycleTest > incrementsTheCounter() STANDARD_OUT
test @f1f7db2 counter=1
PerClassLifecycleTest > incrementsTheCounter() PASSED
PerClassLifecycleTest > incrementsTheCounterAgain() STANDARD_OUT
test @f1f7db2 counter=2
PerClassLifecycleTest > incrementsTheCounterAgain() PASSEDMột instance, một @BeforeAll không static, và bộ đếm mang giá trị sang test sau. Test thứ hai giờ cần test thứ nhất chạy trước, đúng kiểu ràng buộc mà mặc định tránh được. Các test cho service bên dưới giữ mặc định và dựng fixture trong @BeforeEach.
Nhóm test với @Nested và @DisplayName
ProductServiceTest, được trình bày đầy đủ ở phần sau, nhóm test theo method mà chúng kiểm tra. Mỗi class @Nested là một inner class không static, nên test bên trong dùng được field và @BeforeEach của class bên ngoài:
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
// @Mock field and @BeforeEach setUp()
@Nested
@DisplayName("create")
class Create {
@Test
@DisplayName("saves a product whose SKU is free")
void savesANewProduct() {
// ...
}
@Test
@DisplayName("rejects a duplicate SKU and never saves")
void rejectsADuplicateSku() {
// ...
}
}
@Nested
@DisplayName("findById")
class FindById {
@Test
@DisplayName("throws ProductNotFoundException for an unknown id")
void throwsForAnUnknownId() {
// ...
}
}
}Gradle in ra cấu trúc lồng nhau và tên hiển thị:
ProductServiceTest > findById > throws ProductNotFoundException for an unknown id PASSED
ProductServiceTest > create > rejects a duplicate SKU and never saves PASSED
ProductServiceTest > create > saves a product whose SKU is free PASSEDFindById được khai báo sau Create nhưng chạy trước: đó là thứ tự nested class của JUnit 6, và thêm một lý do để không bao giờ cho test này dựa vào test khác.
Parameterized test: @ValueSource, @CsvSource và @MethodSource
Rule về stock trên Product có nhiều ranh giới, đúng việc của parameterized test. ProductTest không cần mock nào:
package com.example.demo.product;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.params.provider.Arguments.arguments;
import java.math.BigDecimal;
import java.util.stream.Stream;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
class ProductTest {
private static Product keyboardWithStock(int stock) {
return new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), stock);
}
@ParameterizedTest
@ValueSource(ints = {0, -1, -10})
void rejectsANonPositiveQuantity(int quantity) {
assertThatIllegalArgumentException()
.isThrownBy(() -> keyboardWithStock(10).decreaseStock(quantity))
.withMessage("Quantity must be positive, was " + quantity);
}
@ParameterizedTest(name = "stock {0} minus {1} leaves {2}")
@CsvSource({
"10, 1, 9",
"10, 10, 0",
"1, 1, 0"
})
void decreasesStock(int stock, int quantity, int remaining) {
Product keyboard = keyboardWithStock(stock);
keyboard.decreaseStock(quantity);
assertThat(keyboard.getStock()).isEqualTo(remaining);
}
@ParameterizedTest
@MethodSource("tooLargeQuantities")
void rejectsMoreThanTheStock(int stock, int quantity, String message) {
assertThatThrownBy(() -> keyboardWithStock(stock).decreaseStock(quantity))
.isInstanceOf(InsufficientStockException.class)
.hasMessage(message);
}
static Stream<Arguments> tooLargeQuantities() {
return Stream.of(
arguments(0, 1, "Only 0 of KB-01 in stock, 1 requested"),
arguments(3, 4, "Only 3 of KB-01 in stock, 4 requested"));
}
@Test
@Disabled("Restocking arrives with the purchasing feature")
void increasesStockOnRestock() {
}
}@ValueSource cung cấp một giá trị cho mỗi lần chạy. @CsvSource tách mỗi dòng thành các argument và chuyển chúng sang type của parameter. @MethodSource("tooLargeQuantities") gọi một static method trong cùng class trả về Stream<Arguments>, là source nên dùng khi argument là object thay vì literal.
./gradlew test --tests ProductTestProductTest > rejectsMoreThanTheStock(int, int, String) > [1] stock = 0, quantity = 1, message = "Only 0 of KB-01 in stock, 1 requested" PASSED
ProductTest > rejectsMoreThanTheStock(int, int, String) > [2] stock = 3, quantity = 4, message = "Only 3 of KB-01 in stock, 4 requested" PASSED
ProductTest > increasesStockOnRestock() SKIPPED
ProductTest > rejectsANonPositiveQuantity(int) > [1] quantity = 0 PASSED
ProductTest > rejectsANonPositiveQuantity(int) > [2] quantity = -1 PASSED
ProductTest > rejectsANonPositiveQuantity(int) > [3] quantity = -10 PASSED
ProductTest > decreasesStock(int, int, int) > stock "10" minus "1" leaves "9" PASSED
ProductTest > decreasesStock(int, int, int) > stock "10" minus "10" leaves "0" PASSED
ProductTest > decreasesStock(int, int, int) > stock "1" minus "1" leaves "0" PASSEDTên mặc định, [{index}] {argumentSetNameOrArgumentsWithNames}, in ra các cặp name = value; tên parameter có được là vì Gradle plugin của Spring Boot compile với -parameters. Tên tự đặt cho decreasesStock cho thấy cách JUnit 6 thêm dấu ngoặc kép: parameter là int, nhưng giá trị của @CsvSource là text cho tới khi được chuyển kiểu, nên {0} in ra "10". quoteTextArguments = false tắt việc đó:
@ParameterizedTest(name = "stock {0} minus {1} leaves {2}")
@ParameterizedTest(name = "stock {0} minus {1} leaves {2}", quoteTextArguments = false)
@CsvSource({ProductTest > decreasesStock(int, int, int) > stock 10 minus 1 leaves 9 PASSED
ProductTest > decreasesStock(int, int, int) > stock 10 minus 10 leaves 0 PASSED
ProductTest > decreasesStock(int, int, int) > stock 1 minus 1 leaves 0 PASSEDBỏ qua test với @Disabled
increasesStockOnRestock ở trên hiện là SKIPPED và không làm build fail. Chuỗi lý do là dành cho người đọc code tiếp theo: JUnit truyền nó cho các listener cùng sự kiện skip, nhưng trong lần chạy này Gradle 9.7.1 chỉ in SKIPPED trên console, và lý do không xuất hiện trong cả HTML report lẫn file XML. Một test bị disable mà không có lý do là test không ai bật lại, nên luôn ghi lý do.
Assertion với AssertJ và thông báo lỗi
Điểm vào của AssertJ là assertThat(actual), trả về một assertion object biết type của giá trị thực tế, nên IDE chỉ gợi ý những phép kiểm tra có nghĩa với type đó: hasSize cho list, isZero cho số, hasMessage cho exception. Mọi assertion bên dưới là static import từ org.assertj.core.api.Assertions.
isEqualTo và bẫy scale của BigDecimal
OrderLine.lineTotal() nhân một đơn giá có scale 2 với số lượng. Phiên bản đầu tiên của test:
package com.example.demo.order;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import org.junit.jupiter.api.Test;
class OrderLineTest {
@Test
void multipliesUnitPriceByQuantity() {
Product cable = new Product("USB-C cable", "CB-02", new BigDecimal("5.00"), 50);
OrderLine line = new OrderLine(cable, 2);
assertThat(line.lineTotal()).isEqualTo(new BigDecimal("10.0"));
}
}OrderLineTest > multipliesUnitPriceByQuantity() FAILED
org.opentest4j.AssertionFailedError:
expected: 10.0
but was: 10.00
at app//com.example.demo.order.OrderLineTest.multipliesUnitPriceByQuantity(OrderLineTest.java:18)Mười vẫn là mười, vậy mà test fail. isEqualTo gọi BigDecimal.equals, so sánh giá trị và cả scale: 5.00 × 2 là 10.00 với scale 2, còn new BigDecimal("10.0") có scale 1. Các cột tiền trong series này có scale = 2, nên chuyện này xảy ra ở mọi test kiểm tra giá hay tổng tiền. isEqualByComparingTo dùng compareTo, bỏ qua scale:
assertThat(line.lineTotal()).isEqualTo(new BigDecimal("10.0"));
assertThat(line.lineTotal()).isEqualByComparingTo(new BigDecimal("10.0")); OrderLineTest > multipliesUnitPriceByQuantity() PASSEDNó cũng nhận một chuỗi, isEqualByComparingTo("204.30"), và test cho order dùng cách đó. Assertion của chính JUnit cũng fail theo cùng kiểu, assertEquals(new BigDecimal("10.0"), line.lineTotal()) báo expected: <10.0> but was: <10.00>: với một giá trị đơn lẻ, hai message mang cùng thông tin, và lợi thế của AssertJ lộ ra ở collection và exception, nơi nó nói rõ đã tìm thấy gì, thiếu gì và có gì không mong đợi.
Collection: containsExactly và extracting
extracting biến đổi từng phần tử trước khi kiểm tra, nên một list entity có thể được so như các giá trị đơn giản. Với hai function, mỗi phần tử thành một tuple:
assertThat(order.getLines())
.extracting(line -> line.getProduct().getStock(), OrderLine::getQuantity)
.containsExactly(tuple(8, 2), tuple(0, 1));containsExactly đòi đúng các phần tử đó, đúng thứ tự và không thêm gì khác; containsExactlyInAnyOrder bỏ yêu cầu thứ tự, còn contains chỉ đòi các phần tử được liệt kê có mặt. Thông báo lỗi của containsExactly xuất hiện trong lần chạy soft assertion bên dưới.
Exception: assertThatThrownBy và assertThatExceptionOfType
Cả hai chạy một lambda, fail nếu lambda không ném gì, rồi kiểm tra thứ nó ném ra. assertThatThrownBy bắt đầu từ lời gọi:
assertThatThrownBy(() -> service.create(copy))
.isInstanceOf(DuplicateSkuException.class)
.hasMessage("SKU KB-01 already exists");assertThatExceptionOfType bắt đầu từ type của exception, và có các lối tắt như assertThatIllegalArgumentException() dùng trong ProductTest:
assertThatExceptionOfType(ProductNotFoundException.class)
.isThrownBy(() -> service.findById(99L))
.withMessage("Product 99 not found");Code phía sau cả hai vẫn tiếp tục chạy, nên test có thể kiểm tra tiếp rằng không có gì được save. Đó là điểm khác với việc bọc lời gọi trong try/catch kèm một fail() trong try, cách rất dễ viết sai.
Soft assertion: nhiều lỗi trong một lần chạy
Assertion thông thường dừng test ở lỗi đầu tiên. Khi một test kiểm tra nhiều thuộc tính của cùng một kết quả, bạn sửa một chỗ, chạy lại, rồi gặp chỗ tiếp theo. assertSoftly gom các lỗi lại và báo cùng lúc:
package com.example.demo.lab;
import static org.assertj.core.api.SoftAssertions.assertSoftly;
import java.math.BigDecimal;
import java.time.Instant;
import com.example.demo.order.Order;
import com.example.demo.order.OrderLine;
import com.example.demo.product.Product;
import org.junit.jupiter.api.Test;
class SoftAssertionsTest {
@Test
void checksTheWholeOrder() {
Product keyboard = new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10);
Order order = new Order(Instant.parse("2026-11-05T09:00:00Z"));
order.addLine(new OrderLine(keyboard, 2));
assertSoftly(softly -> {
softly.assertThat(order.getPlacedAt()).isEqualTo("2026-11-05T10:00:00Z");
softly.assertThat(order.getLines()).extracting(OrderLine::getQuantity).containsExactly(3);
softly.assertThat(order.total()).isEqualByComparingTo("179.80");
softly.assertThat(order.getLines()).hasSize(2);
});
}
}Ba trong bốn kỳ vọng cố ý sai:
SoftAssertionsTest > checksTheWholeOrder() FAILED
org.assertj.core.error.AssertJMultipleFailuresError:
Multiple Failures (3 failures)
-- failure 1 --
expected: 2026-11-05T10:00:00Z
but was: 2026-11-05T09:00:00Z
at SoftAssertionsTest.lambda$checksTheWholeOrder$0(SoftAssertionsTest.java:23)
-- failure 2 --
Expecting actual:
[2]
to contain exactly (and in same order):
[3]
but some elements were not found:
[3]
and others were not expected:
[2]
at SoftAssertionsTest.lambda$checksTheWholeOrder$0(SoftAssertionsTest.java:24)
-- failure 3 --
Expected size: 2 but was: 1 in:
[com.example.demo.order.OrderLine@177c41d7]
at SoftAssertionsTest.lambda$checksTheWholeOrder$0(SoftAssertionsTest.java:26)
at app//com.example.demo.lab.SoftAssertionsTest.checksTheWholeOrder(SoftAssertionsTest.java:22)Đủ cả ba lỗi, mỗi lỗi kèm dòng code, còn tổng tiền ở dòng 25, vốn đúng, không xuất hiện. isEqualTo("2026-11-05T10:00:00Z") trên một Instant sẽ parse chuỗi, giúp assertion về thời gian dễ đọc. OrderLine@177c41d7 là toString() mặc định của Java, vì OrderLine không override nó.
Mockito cho tầng service
@Mock, MockitoExtension và giá trị mặc định
@ExtendWith(MockitoExtension.class) khiến Mockito tạo một mock cho mỗi field @Mock trước mỗi test, kiểm tra các stub sau test, và bắt đầu test tiếp theo với mock mới, nên không có trạng thái nào rò giữa các test. Mock là một object thuộc type của field, các method của nó không làm gì và trả về một giá trị mặc định:
package com.example.demo.lab;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class DefaultAnswersTest {
@Mock
private ProductRepository products;
@Test
void unstubbedCallsReturnDefaults() {
System.out.println("existsBySku(\"KB-01\") -> " + products.existsBySku("KB-01"));
System.out.println("findById(1L) -> " + products.findById(1L));
System.out.println("findAll() -> " + products.findAll());
System.out.println("count() -> " + products.count());
System.out.println("save(product) -> " + products.save(new Product("USB-C hub", "HUB-07", new BigDecimal("39.00"), 5)));
System.out.println("mock -> " + products);
System.out.println("mock class -> " + products.getClass().getName());
}
}DefaultAnswersTest > unstubbedCallsReturnDefaults() STANDARD_OUT
existsBySku("KB-01") -> false
findById(1L) -> Optional.empty
findAll() -> []
count() -> 0
save(product) -> null
mock -> products
mock class -> com.example.demo.product.ProductRepository$MockitoMock$CVaIiiSQ
DefaultAnswersTest > unstubbedCallsReturnDefaults() PASSEDfalse, 0 và null cho primitive type và object thông thường, nhưng là một Optional rỗng và một list rỗng thay vì null, nên một findById chưa stub khiến service đi vào nhánh "không tìm thấy" thay vì ném NullPointerException. Mock được đặt tên theo field, và đó là tên xuất hiện trong thông báo lỗi của Mockito. Mock ghi lại mọi lời gọi tới nó; bản ghi đó là thứ verify đọc.
Constructor injection thay cho @InjectMocks
Mockito cũng có thể tự dựng class đang được test. @InjectMocks chọn constructor lớn nhất và truyền vào các field @Mock khớp type của parameter:
package com.example.demo.order;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class OrderServiceInjectMocksTest {
@Mock
private ProductRepository products;
@Mock
private OrderRepository orders;
@InjectMocks
private OrderService service;
@Test
void placesAnOrder() {
given(products.findById(1L))
.willReturn(Optional.of(new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10)));
service.placeOrder(List.of(new OrderItem(1L, 2)));
then(orders).should().save(any(Order.class));
}
}OrderService có parameter thứ ba trong constructor là Clock, và test không khai báo mock nào cho nó:
OrderServiceInjectMocksTest > placesAnOrder() FAILED
java.lang.NullPointerException: Cannot invoke "java.time.Clock.instant()" because "this.clock" is null
at com.example.demo.order.OrderService.placeOrder(OrderService.java:28)
at com.example.demo.order.OrderServiceInjectMocksTest.placesAnOrder(OrderServiceInjectMocksTest.java:37)Mockito vẫn dựng service và truyền null cho parameter không khớp được, nên lỗi lộ ra dưới dạng NullPointerException bên trong service thay vì ngay chỗ test được thiết lập. Tự gọi constructor trong @BeforeEach biến cùng lỗi đó thành lỗi compile ngay khi service có thêm dependency, và đó là lý do các test trong bài làm như vậy. Đây cũng là lý do bài 7 khuyên dùng constructor injection cho chính các bean.
Stub và verify: when, thenReturn, thenThrow, verify
Stub nói cho mock biết phải trả lời gì với một lời gọi có argument cụ thể. Verify hỏi lại sau đó rằng một lời gọi có xảy ra hay không. API Mockito kiểu cổ điển, trên ProductService.create:
package com.example.demo.product;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;
@ExtendWith(MockitoExtension.class)
class ProductServiceMockitoTest {
@Mock
private ProductRepository products;
private ProductService service;
@BeforeEach
void setUp() {
service = new ProductService(products);
}
@Test
void savesANewProduct() {
Product hub = new Product("USB-C hub", "HUB-07", new BigDecimal("39.00"), 5);
when(products.existsBySku("HUB-07")).thenReturn(false);
when(products.save(hub)).thenReturn(hub);
Product created = service.create(hub);
assertThat(created).isSameAs(hub);
verify(products).existsBySku("HUB-07");
verify(products).save(hub);
verifyNoMoreInteractions(products);
}
@Test
void doesNotSaveADuplicateSku() {
Product copy = new Product("Compact keyboard", "KB-01", new BigDecimal("59.00"), 5);
when(products.existsBySku("KB-01")).thenReturn(true);
assertThatThrownBy(() -> service.create(copy)).isInstanceOf(DuplicateSkuException.class);
verify(products, never()).save(any());
}
@Test
void passesAConstraintViolationThrough() {
Product hub = new Product("USB-C hub", "HUB-07", new BigDecimal("39.00"), 5);
when(products.existsBySku("HUB-07")).thenReturn(false);
when(products.save(hub)).thenThrow(new DataIntegrityViolationException("uk_products_sku"));
assertThatThrownBy(() -> service.create(hub))
.isInstanceOf(DataIntegrityViolationException.class)
.hasMessage("uk_products_sku");
}
}ProductServiceMockitoTest > doesNotSaveADuplicateSku() PASSED
ProductServiceMockitoTest > passesAConstraintViolationThrough() PASSED
ProductServiceMockitoTest > savesANewProduct() PASSEDwhen(mock.call(args)).thenReturn(value)ghi lại một câu trả lời. Argument được so khớp bằngequals, hoặc bằng matcher nhưany(); nếu một argument dùng matcher thì mọi argument đều phải dùng.thenThrowkhiến lời gọi ném exception. Test thứ ba giả lập đúng tình huống race mà constraintuniquetồn tại để chặn: phép kiểm tra qua, rồisavefail, và service phải để exception đi tiếp cho advice map thành 409.verify(mock).call(args)kiểm tra lời gọi xảy ra đúng một lần;verify(mock, never())kiểm tra nó không xảy ra, còntimes(n)vàatLeastOnce()lo các trường hợp còn lại.verifyNoMoreInteractions(mock)fail nếu mock nhận một lời gọi mà khôngverifynào tính tới. Dùng dè dặt: nó biến mọi lời gọi thừa vô hại thành lỗi.
Dưới MockitoExtension, một lời gọi khớp với stub được tính là đã verify, nên verifyNoMoreInteractions chỉ báo những lời gọi bạn không stub và cũng không verify. Cùng test SKU trùng có thêm verifyNoMoreInteractions, trong một class tắt strictness:
package com.example.demo.product;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ProductServiceLenientVerifyTest {
@Mock
private ProductRepository products;
@Test
void doesNotSaveADuplicateSku() {
Product copy = new Product("Compact keyboard", "KB-01", new BigDecimal("59.00"), 5);
when(products.existsBySku("KB-01")).thenReturn(true);
assertThatThrownBy(() -> new ProductService(products).create(copy)).isInstanceOf(DuplicateSkuException.class);
verify(products, never()).save(any());
verifyNoMoreInteractions(products);
}
}ProductServiceLenientVerifyTest > doesNotSaveADuplicateSku() FAILED
org.mockito.exceptions.verification.NoInteractionsWanted:
No interactions wanted here:
-> at com.example.demo.product.ProductServiceLenientVerifyTest.doesNotSaveADuplicateSku(ProductServiceLenientVerifyTest.java:34)
But found this interaction on mock 'products':
-> at com.example.demo.product.ProductService.create(ProductService.java:22)
Actually, above is the only interaction with this mock.
at app//com.example.demo.product.ProductServiceLenientVerifyTest.doesNotSaveADuplicateSku(ProductServiceLenientVerifyTest.java:34)Interaction chưa verify chính là lời gọi existsBySku đã stub ở dòng 22 của ProductService. Với strictness mặc định, cùng assertion đó trong ProductServiceTest bên dưới pass.
Kiểu BDD: given, willReturn, then
BDDMockito cung cấp cùng các thao tác dưới những cái tên theo bố cục given / when / then, để phần stub đọc như "given" và phần verify đọc như "then":
Mockito cổ điển | BDDMockito |
|---|---|
when(mock.call()).thenReturn(value) | given(mock.call()).willReturn(value) |
when(mock.call()).thenThrow(exception) | given(mock.call()).willThrow(exception) |
when(mock.call()).thenAnswer(answer) | given(mock.call()).willAnswer(answer) |
verify(mock).call() | then(mock).should().call() |
verify(mock, never()).call() | then(mock).should(never()).call() |
verifyNoMoreInteractions(mock) | then(mock).shouldHaveNoMoreInteractions() |
Hành vi giống hệt nhau; mỗi code base chọn một kiểu. Các test từ đây trở đi dùng BDDMockito.
ArgumentCaptor: assert trên object được truyền vào save
OrderService.placeOrder tạo Order bên trong method rồi đưa cho orders.save. Test không bao giờ giữ reference tới object đó, nên không so sánh bằng equals được. Một ArgumentCaptor bắt argument trong lúc verify:
then(orders).should().save(savedOrder.capture());
Order order = savedOrder.getValue();savedOrder là một field có @Captor, được MockitoExtension khởi tạo giống như mock. Sau getValue(), test có đúng object mà service đã dựng và có thể assert trên thời gian, các dòng và tổng tiền của nó. Product thì khác: test tự tạo chúng và trả về từ findById đã stub, nên đọc thẳng stock của chúng được.
![Bốn bước theo thứ tự: @Mock tạo một mock thế chỗ với giá trị mặc định; given(products.findById(1L)).willReturn(Optional.of(keyboard)) ghi lại câu trả lời; service gọi products.findById(1L) và nhận Optional[keyboard] trong khi orders.save(order) được ghi lại; then(orders).should().save(savedOrder.capture()) kiểm tra Order do service tạo; các nhánh lỗi cho thấy UnnecessaryStubbingException với stub không dùng và PotentialStubbingProblem khi stub findById(3L) nhưng gọi findById(2L)](/images/blog/sb-mockito-stub-verify-flow.vi.webp)
Strict stubs: UnnecessaryStubbingException và PotentialStubbingProblem
MockitoExtension mặc định chạy với strict stubs, và biến hai loại lỗi trong test thành test fail. Loại thứ nhất là một stub không ai dùng. Stub dùng chung trong setUp là cách thường gặp nhất để có một cái như vậy, ví dụ cho save trả về chính argument của nó ở mọi test:
@BeforeEach
void setUp() {
service = new OrderService(products, orders, Clock.fixed(NOW, ZoneOffset.UTC));
keyboard = new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10);
mouse = new Product("Wireless mouse", "MS-01", new BigDecimal("24.50"), 1);
given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0));
}OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() FAILED
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
Following stubbings are unnecessary (click to navigate to relevant line of code):
1. -> at com.example.demo.order.OrderServiceTest.setUp(OrderServiceTest.java:53)
Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.
at app//org.mockito.junit.jupiter.MockitoExtension.lambda$afterEach$2(MockitoExtension.java:200)
at java.base@21.0.6/java.util.Optional.ifPresent(Optional.java:178)
at app//org.mockito.junit.jupiter.MockitoExtension.afterEach(MockitoExtension.java:198)
at java.base@21.0.6/java.util.ArrayList.forEach(ArrayList.java:1596)
at java.base@21.0.6/java.util.ArrayList.forEach(ArrayList.java:1596)Test fail lại chính là test có mọi assertion của nó đều pass: nhánh thiếu stock không bao giờ tới save, và phép kiểm tra chạy trong MockitoExtension.afterEach. Lỗi này hữu ích, vì một stub không nhánh code nào dùng thường có nghĩa là test không đi qua nhánh mà người viết nghĩ. lenient() đánh dấu một stub được phép không dùng tới:
given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0));
lenient().when(orders.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0)); OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSEDlenient() là static method của org.mockito.Mockito và trả về một stubber có when(...) nhưng không có given(...), nên riêng dòng đó dùng tên kiểu cổ điển. Cách sửa tốt hơn thường là chuyển stub vào test cần nó; OrderServiceTest bản cuối không cần stub save nào.
Loại thứ hai là stub được gọi với argument khác. Ở đây test stub product 3 trong khi order hỏi product 2:
@Test
void decrementsStockAndSavesTheOrder() {
given(products.findById(1L)).willReturn(Optional.of(keyboard));
given(products.findById(2L)).willReturn(Optional.of(mouse));
given(products.findById(3L)).willReturn(Optional.of(mouse));
service.placeOrder(List.of(new OrderItem(1L, 2), new OrderItem(2L, 1)));OrderServiceTest > decrementsStockAndSavesTheOrder() FAILED
org.mockito.exceptions.misusing.PotentialStubbingProblem:
Strict stubbing argument mismatch. Please check:
- this invocation of 'findById' method:
products.findById(2L);
-> at com.example.demo.order.OrderService.placeOrder(OrderService.java:30)
- has following stubbing(s) with different arguments:
1. products.findById(3L); stubbed with: [Returns: Optional[com.example.demo.product.Product@f9f3928]]
-> at com.example.demo.order.OrderServiceTest.decrementsStockAndSavesTheOrder(OrderServiceTest.java:58)
Typically, stubbing argument mismatch indicates user mistake when writing tests.
Mockito fails early so that you can debug potential problem easily.
However, there are legit scenarios when this exception generates false negative signal:
- stubbing the same method multiple times using 'given().will()' or 'when().then()' API
Please use 'will().given()' or 'doReturn().when()' API for stubbing.
- stubbed method is intentionally invoked with different arguments by code under test
Please use default or 'silent' JUnit Rule (equivalent of Strictness.LENIENT).
For more information see javadoc for PotentialStubbingProblem class.
at app//com.example.demo.order.OrderService.placeOrder(OrderService.java:30)
at app//com.example.demo.order.OrderServiceTest.decrementsStockAndSavesTheOrder(OrderServiceTest.java:60)Chạy với @MockitoSettings(strictness = Strictness.LENIENT), cùng lỗi đó fail với ProductNotFoundException: Product 2 not found tại OrderService.java:31: lời gọi không khớp trả về Optional.empty mặc định, và lỗi chỉ vào service thay vì vào test. Strict stubs ném exception ngay tại lời gọi và nêu tên cả invocation lẫn stub suýt khớp.
Unit test cho ProductService và OrderService
ProductServiceTest: SKU trùng không bao giờ được save
package com.example.demo.product;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
import java.math.BigDecimal;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@Mock
private ProductRepository products;
private ProductService service;
@BeforeEach
void setUp() {
service = new ProductService(products);
}
@Nested
@DisplayName("create")
class Create {
@Test
@DisplayName("saves a product whose SKU is free")
void savesANewProduct() {
Product hub = new Product("USB-C hub", "HUB-07", new BigDecimal("39.00"), 5);
given(products.existsBySku("HUB-07")).willReturn(false);
given(products.save(hub)).willReturn(hub);
assertThat(service.create(hub)).isSameAs(hub);
}
@Test
@DisplayName("rejects a duplicate SKU and never saves")
void rejectsADuplicateSku() {
Product copy = new Product("Compact keyboard", "KB-01", new BigDecimal("59.00"), 5);
given(products.existsBySku("KB-01")).willReturn(true);
assertThatThrownBy(() -> service.create(copy))
.isInstanceOf(DuplicateSkuException.class)
.hasMessage("SKU KB-01 already exists");
then(products).should(never()).save(any());
then(products).shouldHaveNoMoreInteractions();
}
}
@Nested
@DisplayName("findById")
class FindById {
@Test
@DisplayName("throws ProductNotFoundException for an unknown id")
void throwsForAnUnknownId() {
given(products.findById(99L)).willReturn(Optional.empty());
assertThatExceptionOfType(ProductNotFoundException.class)
.isThrownBy(() -> service.findById(99L))
.withMessage("Product 99 not found");
}
}
}Test SKU trùng kiểm tra rule từ cả hai phía: exception và message của nó, rồi save chưa từng được gọi và không có gì khác chạm vào repository. Một assertion chỉ trên exception vẫn pass nếu một thay đổi sau này save product trước rồi mới ném exception. Field @Mock của class bên ngoài cũng được khởi tạo cho các test @Nested, nhờ vậy các stub bên trong Create và FindById hoạt động.
ProductServiceTest > findById > throws ProductNotFoundException for an unknown id PASSED
ProductServiceTest > create > rejects a duplicate SKU and never saves PASSED
ProductServiceTest > create > saves a product whose SKU is free PASSEDOrderServiceTest: stock, order được save và nhánh thiếu stock
package com.example.demo.order;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import com.example.demo.product.InsufficientStockException;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
private static final Instant NOW = Instant.parse("2026-11-05T09:00:00Z");
@Mock
private ProductRepository products;
@Mock
private OrderRepository orders;
@Captor
private ArgumentCaptor<Order> savedOrder;
private OrderService service;
private Product keyboard;
private Product mouse;
@BeforeEach
void setUp() {
service = new OrderService(products, orders, Clock.fixed(NOW, ZoneOffset.UTC));
keyboard = new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10);
mouse = new Product("Wireless mouse", "MS-01", new BigDecimal("24.50"), 1);
}
@Test
void decrementsStockAndSavesTheOrder() {
given(products.findById(1L)).willReturn(Optional.of(keyboard));
given(products.findById(2L)).willReturn(Optional.of(mouse));
service.placeOrder(List.of(new OrderItem(1L, 2), new OrderItem(2L, 1)));
then(orders).should().save(savedOrder.capture());
Order order = savedOrder.getValue();
assertThat(order.getPlacedAt()).isEqualTo("2026-11-05T09:00:00Z");
assertThat(order.getLines())
.extracting(line -> line.getProduct().getStock(), OrderLine::getQuantity)
.containsExactly(tuple(8, 2), tuple(0, 1));
assertThat(order.total()).isEqualByComparingTo("204.30");
}
@Test
void savesNothingWhenALineHasTooLittleStock() {
given(products.findById(1L)).willReturn(Optional.of(keyboard));
given(products.findById(2L)).willReturn(Optional.of(mouse));
assertThatThrownBy(() -> service.placeOrder(List.of(new OrderItem(1L, 2), new OrderItem(2L, 3))))
.isInstanceOf(InsufficientStockException.class)
.hasMessage("Only 1 of MS-01 in stock, 3 requested");
then(orders).should(never()).save(any());
assertThat(keyboard.getStock()).isEqualTo(8);
}
}Happy path không stub save, nên mock trả về null và test bỏ qua giá trị trả về; thứ nó kiểm tra là những gì service đưa cho repository. Order bị bắt giữ mang thời gian cố định, mỗi item một dòng với stock của bàn phím giảm từ 10 xuống 8 và của chuột từ 1 xuống 0, và tổng tiền 2 × 89.90 + 24.50.
OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSEDAssertion cuối của nhánh fail đáng đọc hai lần. Dòng bàn phím được xử lý trước khi dòng chuột fail, nên object bàn phím trong bộ nhớ ở mức 8. Trong ứng dụng không mất gì, vì placeOrder có @Transactional và bài 30 đã cho thấy rollback khi gặp InsufficientStockException. Trong test này không có transaction, và unit test chỉ chứng minh được save chưa từng được gọi. Database có thật sự giữ nguyên 10 hay không là câu hỏi cho một test có transaction thật và database thật, đúng hướng bài tiếp theo đi.
Cố định thời gian bằng Clock được inject
Một service gọi Instant.now() cho ra giá trị khác nhau mỗi lần chạy, và test chỉ kiểm tra được rằng thời gian là "gần đây". Vì OrderService nhận một Clock, test truyền vào một clock luôn trả về cùng một thời điểm:
private static final Instant NOW = Instant.parse("2026-11-05T09:00:00Z");
@BeforeEach
void setUp() {
service = new OrderService(products, orders, Clock.fixed(NOW, ZoneOffset.UTC));và assert đúng giá trị, assertThat(order.getPlacedAt()).isEqualTo("2026-11-05T09:00:00Z"). Clock.fixed là một Clock thật của JDK, không phải mock: không có gì để stub, và nó trả lời instant(), millis() và getZone() một cách nhất quán. Khi chạy thật, bean ClockConfig cung cấp Clock.systemUTC(). Với thời gian cần trôi trong lúc test, ví dụ một thời hạn hết hiệu lực, Clock.offset(clock, Duration.ofMinutes(15)) trả về một clock lệch so với clock ban đầu.
Những gì không nên mock
Mock thay thế hành vi mà bạn không muốn chạy trong test này. Mọi thứ khác nên là thật:
- Chính class đang được test. Một partial mock hay spy của
OrderServicelà đang test cơ chế stub của Mockito thay vì code của bạn. - Value object, record và DTO.
OrderItem,BigDecimal,Instant, một request record: cứ tạo chúng. Chúng không có dependency vàequalshoạt động đúng trên chúng. - Entity và rule của nó.
Productlà object thật trongOrderServiceTest, nêndecreaseStockvà exception của nó chạy thật bên trong test của service. MockProductđồng nghĩa với viết lại rule về stock bằng các stub. - Type đã có sẵn bản thay thế cho test.
Clock.fixedcho thời gian, mộtArrayListthật thay vì mock mộtList. - Query của repository. Stub
existsBySkukiểm tra cách service phản ứng vớitruevàfalse; nó không nói gì về việc bản thân query có đúng hay không. Việc đó cần database.
Thứ còn lại là ranh giới do bạn sở hữu: các repository interface, và các interface bạn tự định nghĩa quanh hệ thống bên ngoài như một payment client. Bài 21 đã cho thấy Mockito 5.23.0 mock một class cụ thể như ProductService cũng được, nên mock class là làm được; lý do nên mock interface ranh giới của chính mình là chúng đổi khi code của bạn đổi, còn class của bên thứ ba có thể đổi ngay bên dưới các stub.
Cảnh báo Mockito self-attaching trên JDK 21
Lần đầu Mockito khởi tạo trong JVM của test, nó in dòng này ra standard error. Nó hiện ra khi có showStandardStreams = true, còn không thì chỉ nằm trong report, dưới test class nào chạy trước và chạm tới Mockito; trong lần chạy này đó là DemoApplicationTests do Initializr sinh ra:
DemoApplicationTests > contextLoads() STANDARD_ERROR
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
WARNING: A Java agent has been loaded dynamically (.../byte-buddy-agent-1.18.11.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future releaseInline mock maker mặc định của Mockito 5 viết lại class tại chỗ qua instrumentation API của JVM, và khi không có agent trên dòng lệnh, nó lấy API đó bằng cách gắn agent của Byte Buddy vào JVM đang chạy. Từ JDK 21 (JEP 451), JVM cảnh báo khi một agent được nạp theo cách đó, và dòng cảnh báo cuối cho biết một bản phát hành tương lai sẽ mặc định không cho phép; chính message của Mockito cũng nói vậy về việc self-attaching. Tài liệu của Mockito khuyên nạp mockito-core dưới dạng -javaagent ngay khi JVM của test khởi động:
configurations {
mockitoAgent
}
dependencies {
// the dependencies Initializr generated, unchanged
mockitoAgent('org.mockito:mockito-core') {
transitive = false
}
}
tasks.named('test') {
useJUnitPlatform()
jvmArgs += "-javaagent:${configurations.mockitoAgent.asPath}"
testLogging {
events 'passed', 'skipped', 'failed'
showStandardStreams = true
exceptionFormat = 'full'
}
}<properties>
<java.version>21</java.version>
<argLine/>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
</configuration>
</plugin>
</plugins>
</build>Với Gradle, configuration mockitoAgent chỉ chứa jar mockito-core, không cần version vì dependency management của Boot cung cấp, và asPath biến nó thành đường dẫn cho -javaagent:
./gradlew -q dependencies --configuration mockitoAgentmockitoAgent
\--- org.mockito:mockito-core -> 5.23.0Với Maven, goal properties của maven-dependency-plugin định nghĩa cho mỗi dependency một property mang tên nó, ${org.mockito:mockito-core:jar}, chứa đường dẫn tới jar. Property <argLine/> rỗng không phải để trang trí. Thiếu nó, trên project Boot 4.1.1 này, @{argLine} không có gì để thay thế, Surefire truyền nguyên văn cho java, và build fail trước khi test nào kịp chạy (đường dẫn được rút gọn):
[ERROR] The forked VM terminated without properly saying goodbye. VM crash or System.exit called?
[ERROR] Command was /bin/sh -c cd '.../demo-mvn' && '.../bin/java' '@{argLine}' '-javaagent:.../mockito-core/5.23.0/mockito-core-5.23.0.jar' '-jar' ...Thêm property vào, ./mvnw test chạy 16 test với 1 test bị skip và không in cảnh báo Mockito nào. Build Gradle, sau khi cấu hình agent và lược log khởi động của Spring:
> Task :testClasses UP-TO-DATE
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
DemoApplicationTests > contextLoads() PASSED
OrderLineTest > multipliesUnitPriceByQuantity() PASSED
OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSED
ProductServiceTest > findById > throws ProductNotFoundException for an unknown id PASSED
ProductServiceTest > create > rejects a duplicate SKU and never saves PASSED
ProductServiceTest > create > saves a product whose SKU is free PASSEDKhối STANDARD_ERROR với message của Mockito và bốn cảnh báo của JDK đã biến mất. Còn lại một dòng của JVM. Nó không liên quan tới cách agent được nạp: chạy riêng OrderLineTest, test không tạo mock nào, thì dòng này không xuất hiện, dù có hay không có agent. Nó xuất hiện khi inline mock maker của Mockito khởi tạo và nối các helper class của nó vào bootstrap class path, sau đó class data sharing của JVM chỉ còn áp dụng cho các class của boot loader. Dòng này chỉ mang tính thông tin, và mọi test đều pass.
Unit test chạy nhanh đến mức nào?
Report của Gradle cho thấy thời gian của từng class và từng test. Với hai test class của service, chạy riêng khi đã cấu hình agent, lấy kết quả tốt nhất trong ba lần chạy (các con số chỉ mang tính tham khảo):
./gradlew test --rerun --tests '*ServiceTest'| Test | Thời gian |
|---|---|
OrderServiceTest (2 test) | 0.219 s |
decrementsStockAndSavesTheOrder(), test đầu tiên trong JVM | 0.212 s |
savesNothingWhenALineHasTooLittleStock() | 0.002 s |
ProductServiceTest (3 test, hai class @Nested) | 0.005 s |
Gần như toàn bộ 0.219 s là chi phí khởi tạo một lần mà test đầu tiên trong JVM phải trả: tạo những mock đầu tiên và load class. Mọi test sau đó chỉ mất một đến ba mili giây. Trong một lần chạy đầy đủ, khi DemoApplicationTests do Initializr sinh ra chạy trước, cùng class đó mất 0.134 s vì một phần khởi tạo đã xảy ra từ trước, còn bản thân DemoApplicationTests tốt nhất mất 1.559 s, gần như toàn bộ là để khởi động application context cho một test method chỉ mất 0.04 s. Đó là mốc so sánh cho bài tiếp theo, nơi các test cố ý khởi động Spring.
FAQ
Chuyển từ JUnit 5 lên JUnit 6 thì test của tôi thay đổi gì?
Với phần lớn code test, không gì cả: annotation, assertion và extension trong org.junit.jupiter.api vẫn như cũ. JUnit 6 cần Java 17 trở lên, các artifact của Platform như junit-platform-launcher giờ dùng chung version với Jupiter, text argument trong tên parameterized test được đặt trong dấu ngoặc kép (tắt bằng quoteTextArguments = false), các class @Nested chạy theo thứ tự xác định nhưng cố ý không hiển nhiên, và các CSV source được FastCSV parse. Spring Boot 4.1.1 quản lý tất cả, nên project do Initializr tạo không cần đổi version nào.
Nên dùng @InjectMocks hay tự gọi constructor?
Tự gọi constructor trong @BeforeEach. @InjectMocks truyền null cho mọi parameter của constructor không có @Mock khớp: OrderService dựng theo cách đó fail về sau với Cannot invoke "java.time.Clock.instant()" because "this.clock" is null. Lời gọi constructor sẽ ngừng compile khi service có thêm dependency, đó là cảnh báo sớm nhất có thể.
Vì sao Mockito ném UnnecessaryStubbingException?
Vì MockitoExtension dùng strict stubs và có một stub trong test, hoặc trong @BeforeEach của nó, chưa từng được gọi. Lỗi được báo sau khi test chạy xong, nên test fail kể cả khi mọi assertion của nó đều pass. Xóa stub đó, chuyển nó vào các test dùng tới nó, hoặc đánh dấu riêng stub đó bằng lenient() khi nó được dùng chung có chủ đích.
So sánh BigDecimal trong AssertJ thế nào?
Dùng isEqualByComparingTo. isEqualTo dùng BigDecimal.equals, so cả scale, nên 10.00 không bằng 10.0 và test fail với expected: 10.0 but was: 10.00. isEqualByComparingTo(new BigDecimal("10.0")) hoặc isEqualByComparingTo("10.0") chỉ so giá trị số.
Unit test có cần @SpringBootTest không?
Không, và không nên dùng. Unit test cho service dựng service bằng new và mock các repository của nó, nên không có context nào để khởi động: ProductServiceTest chạy ba test trong 5 ms, trong khi class @SpringBootTest do Initializr sinh ra cần 1.559 s để khởi động context. Test có Spring dành cho những gì unit test không thấy được, như mapping, query và transaction.
Làm sao bỏ cảnh báo "Mockito is currently self-attaching"?
Nạp mockito-core dưới dạng Java agent ngay khi JVM của test khởi động. Với Gradle, thêm configuration mockitoAgent chứa mockito-core (không transitive) và jvmArgs += "-javaagent:${configurations.mockitoAgent.asPath}" trong task test. Với Maven, dùng goal properties của maven-dependency-plugin và @{argLine} -javaagent:${org.mockito:mockito-core:jar} trong argLine của Surefire, kèm một property <argLine/> rỗng, nếu không JVM con sẽ không khởi động được.
Kết luận
Unit test cho một service là service được tạo bằng new, repository của nó được thay bằng mock của Mockito và thời gian được thay bằng Clock.fixed, không Spring context, không database. Gradle chạy nó qua useJUnitPlatform(), testLogging đưa kết quả và thông báo lỗi ra console, và --tests thu hẹp lần chạy xuống một class hoặc một method. JUnit 6 tạo một test instance mới cho mỗi method, điều mà các identity được in ra đã cho thấy, nhóm test bằng @Nested, và chạy parameterized test với tên giờ đặt text argument trong dấu ngoặc kép.
Thông báo lỗi của AssertJ hữu ích nhất đúng ở chỗ test hay sai: isEqualTo trên BigDecimal so cả scale, và assertSoftly báo ba lỗi trong một lần chạy. MockitoExtension của Mockito thêm strict stubs, làm test fail vì một stub không dùng tới và ném exception tại một lời gọi có argument không khớp, còn ArgumentCaptor cho phép chạm tới Order mà service đã dựng. Các test cho rule thật xác nhận SKU trùng không bao giờ được save và một order thiếu stock không save gì, mỗi test vài mili giây, sau khi Mockito được nạp dưới dạng agent và cảnh báo của nó biến mất.
Điều các test này không cho thấy được là các mảnh có khớp với nhau hay không. Bài tiếp theo test cùng Spring: @SpringBootTest cho toàn bộ ứng dụng, @WebMvcTest với MockMvc và MockMvcTester cho controller, và @DataJpaTest cho repository trên một database thật.