Command Palette

Search for a command to run...

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

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-jpah2, cùng các thư viện test do Boot quản lý: JUnit 6, AssertJ và Mockito 5.

Một class đang được test với dấu tick xanh, nối tới hai mock @Mock viền nét đứt

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 ProductServiceOrderService.

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.

Cấu trúc OrderServiceTest: OrderService là class thật, ProductRepository và OrderRepository là @Mock, Clock là Clock.fixed; Product, Order, OrderLine và OrderItem là object thật; Spring ApplicationContext, proxy @Transactional, Hibernate và database không tham gia; đo được 0.219 s cho 2 test

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

src/main/java/com/example/demo/product/Product.java
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
}
src/main/java/com/example/demo/product/InsufficientStockException.java
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");
    }
}

DuplicateSkuExceptionProductNotFoundException có cùng hình dạng, với message SKU KB-01 already existsProduct 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.

src/main/java/com/example/demo/product/ProductRepository.java
package com.example.demo.product;
 
import org.springframework.data.jpa.repository.JpaRepository;
 
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    boolean existsBySku(String sku);
}
src/main/java/com/example/demo/product/ProductService.java
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:

src/main/java/com/example/demo/order/Order.java
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
}
src/main/java/com/example/demo/order/OrderLine.java
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
}
src/main/java/com/example/demo/order/OrderItem.java
package com.example.demo.order;
 
public record OrderItem(Long productId, int quantity) {
}
src/main/java/com/example/demo/order/OrderRepository.java
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):

src/main/java/com/example/demo/order/OrderService.java
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);
    }
}
src/main/java/com/example/demo/common/ClockConfig.java
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@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, validationwebmvc, cộng thêm JUnit Platform launcher, và cấu hình task test chạy trên JUnit Platform:

build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-h2console'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-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:

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

Text
> 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ả.

Bash
./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.]+$'
Text
|    +--- 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.3

Test 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

Bash
./gradlew test

Khi mọi test trong bài đều pass, output mặc định gần như không nói gì:

Text
> 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.html

Khô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:

Text
> 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.html

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

build.gradle
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.outSystem.err, còn exceptionFormat = 'full' in message lỗi và stack trace. Cùng test đang fail đó, chạy riêng bằng --tests:

Bash
./gradlew test --tests OrderLineTest
Text
> 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 failed

Giờ lý do đã hiện trên màn hình. Phần AssertJ giải thích vì sao 10.010.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:

Bash
./gradlew test --tests 'OrderServiceTest.savesNothingWhenALineHasTooLittleStock'
Text
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSED
 
BUILD SUCCESSFUL in 1s

Mọi class có tên kết thúc bằng ServiceTest:

Bash
./gradlew test --tests '*ServiceTest'
Text
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 1s

Gradle 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.

AnnotationCô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 / @AfterEachChạy trước và sau mỗi test method, trên instance của test đó
@BeforeAll / @AfterAllChạ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
@NestedMột inner class gom các test liên quan, có lifecycle method riêng
@ParameterizedTestChạy method một lần cho mỗi bộ argument lấy từ một source annotation
@ValueSourceMột argument cho mỗi lần chạy, từ một mảng literal
@CsvSourceNhiều argument cho mỗi lần chạy, từ các dòng CSV
@MethodSourceArgument 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 @BeforeAllstatic: 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 đó:

src/test/java/com/example/demo/lab/LifecycleTest.java
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);
    }
}
Bash
./gradlew test --tests LifecycleTest
Text
LifecycleTest 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: 2

Hai 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.

Trace của LifecycleTest theo thời gian: @BeforeAll với 0 instance, rồi instance #1 @590adb41 chạy constructor, @BeforeEach, incrementsTheCounter() với counter=1 và @AfterEach, rồi instance #2 @2daf06fc chạy các bước tương tự cho incrementsTheCounterAgain() với counter=1, rồi @AfterAll với 2 instance đã tạo; một khung PER_CLASS cho thấy một instance @f1f7db2 với counter 1 rồi 2

@TestInstance(PER_CLASS) tắt hành vi đó:

src/test/java/com/example/demo/lab/PerClassLifecycleTest.java
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);
    }
}
Text
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() PASSED

Mộ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:

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

Text
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

FindById đượ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:

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

Bash
./gradlew test --tests ProductTest
Text
ProductTest > 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" PASSED

Tê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 đó:

src/test/java/com/example/demo/product/ProductTest.java
    @ParameterizedTest(name = "stock {0} minus {1} leaves {2}") 
    @ParameterizedTest(name = "stock {0} minus {1} leaves {2}", quoteTextArguments = false) 
    @CsvSource({
Text
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 PASSED

Bỏ 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:

src/test/java/com/example/demo/order/OrderLineTest.java
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"));
    }
}
Text
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 × 210.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:

src/test/java/com/example/demo/order/OrderLineTest.java
        assertThat(line.lineTotal()).isEqualTo(new BigDecimal("10.0")); 
        assertThat(line.lineTotal()).isEqualByComparingTo(new BigDecimal("10.0")); 
Text
OrderLineTest > multipliesUnitPriceByQuantity() PASSED

Nó 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:

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

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

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

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

Text
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@177c41d7toString() 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:

src/test/java/com/example/demo/lab/DefaultAnswersTest.java
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());
    }
}
Text
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() PASSED

false, 0null 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:

src/test/java/com/example/demo/order/OrderServiceInjectMocksTest.java
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ó:

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

src/test/java/com/example/demo/product/ProductServiceMockitoTest.java
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");
    }
}
Text
ProductServiceMockitoTest > doesNotSaveADuplicateSku() PASSED
 
ProductServiceMockitoTest > passesAConstraintViolationThrough() PASSED
 
ProductServiceMockitoTest > savesANewProduct() PASSED
  • when(mock.call(args)).thenReturn(value) ghi lại một câu trả lời. Argument được so khớp bằng equals, hoặc bằng matcher như any(); nếu một argument dùng matcher thì mọi argument đều phải dùng.
  • thenThrow khiến lời gọi ném exception. Test thứ ba giả lập đúng tình huống race mà constraint unique tồn tại để chặn: phép kiểm tra qua, rồi save fail, 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òn times(n)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ông verify nà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:

src/test/java/com/example/demo/product/ProductServiceLenientVerifyTest.java
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);
    }
}
Text
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ểnBDDMockito
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:

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

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:

src/test/java/com/example/demo/order/OrderServiceTest.java
    @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)); 
    }
Text
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:

src/test/java/com/example/demo/order/OrderServiceTest.java
        given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0)); 
        lenient().when(orders.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0)); 
Text
OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
 
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSED

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

src/test/java/com/example/demo/order/OrderServiceTest.java
    @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)));
Text
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

src/test/java/com/example/demo/product/ProductServiceTest.java
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 CreateFindById hoạt động.

Text
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

OrderServiceTest: stock, order được save và nhánh thiếu stock

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

Text
OrderServiceTest > decrementsStockAndSavesTheOrder() PASSED
 
OrderServiceTest > savesNothingWhenALineHasTooLittleStock() PASSED

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

src/test/java/com/example/demo/order/OrderServiceTest.java
    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()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 OrderService là đ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à equals hoạt động đúng trên chúng.
  • Entity và rule của nó. Product là object thật trong OrderServiceTest, nên decreaseStock và exception của nó chạy thật bên trong test của service. Mock Product đồ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.fixed cho thời gian, một ArrayList thật thay vì mock một List.
  • Query của repository. Stub existsBySku kiểm tra cách service phản ứng với truefalse; 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:

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

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

build.gradle
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'
    }
}

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:

Bash
./gradlew -q dependencies --configuration mockitoAgent
Text
mockitoAgent
\--- org.mockito:mockito-core -> 5.23.0

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

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

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

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

Bash
./gradlew test --rerun --tests '*ServiceTest'
TestThời gian
OrderServiceTest (2 test)0.219 s
decrementsStockAndSavesTheOrder(), test đầu tiên trong JVM0.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?

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@{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.

Bài viết liên quan

[Spring Boot Basics] Phân trang và sắp xếp trong Spring Boot: Pageable, Sort và trả kết quả phân trang qua API

Phân trang và sắp xếp trong Spring Boot 4.1.1 với Spring Data JPA, trên H2 và PostgreSQL: Sort với ignoreCase, nullsFirst, nullsLast và SQL mỗi cách sinh ra trên hai database, TypedSort đã deprecated, PageRequest đếm từ 0, Page, Slice và List cùng count query và row thừa phía sau từng loại, khi nào Spring Data bỏ count, @Query và native query với Pageable, totalElements bị thổi phồng khi phân trang JOIN FETCH, controller nhận Pageable với @PageableDefault, max-page-size và one-indexed parameter, warning khi serialize PageImpl, PagedModel so với record PageResponse, ProblemDetail 400 cho sort property không tồn tại, và OFFSET so với keyset scrolling bằng Window.

[Spring Boot Basics] Logging trong Spring Boot: SLF4J, Logback, log level và ghi log ra file

Logging trong Spring Boot 4.1.1: SLF4J là facade và Logback là implementation, hai bridge jul-to-slf4j và log4j-to-slf4j, parameterised và fluent logging, ghi log exception, log level, cây logger và log group, --debug so với --trace, pattern dòng log mặc định, logging.file.name kèm rotation, logback-spring.xml với springProfile, MDC và chuyển sang Log4j2.

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

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

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