JUnit 5 và AssertJ

4 câu hỏi~20 phútTừ khóa: Jupiter, @BeforeEach, @BeforeAll, lifecycle PER_METHOD, AssertJ, assertThrows, @ParameterizedTest, @CsvSource, @Nested, @DisplayName, test isolation

Tiếp đi: JUnit 5. Mình muốn test DiscountPolicy với cả chục bộ số, chẳng lẽ copy chục method @Test?

Không cần. JUnit 5 có @ParameterizedTest cho đúng việc này. Mình đi từ lifecycle tới assertion rồi tới cách tổ chức test.

1. Bài toán: DiscountPolicy

public class DiscountPolicy {
    public Money apply(Money subtotal, CustomerTier tier) {
        if (subtotal.isNegative()) throw new IllegalArgumentException("subtotal < 0");
        int percent = switch (tier) {
            case REGULAR -> subtotal.isGreaterThan(Money.of(1_000)) ? 5 : 0;
            case VIP     -> 10;
            case STAFF   -> 30;
        };
        return subtotal.minusPercent(percent);
    }
}

Logic thuần, không Spring, không DB: chỗ lý tưởng cho unit test nhanh, nhiều case biên (đúng 1.000, 0, số âm).

2. Vấn đề

3. Giải pháp: các thành phần JUnit 5

4. Code từng bước

Bước 1: Dependency. Có sẵn trong spring-boot-starter-test (JUnit Jupiter, AssertJ, junit-jupiter-params).

Bước 2: Lifecycle

class DiscountPolicyTest {
    private DiscountPolicy policy;

    @BeforeEach              // chạy trước MỖI test → mỗi test có object mới
    void setUp() { policy = new DiscountPolicy(); }
}

Bước 3: Code test

@DisplayName("DiscountPolicy")
class DiscountPolicyTest {
    private final DiscountPolicy policy = new DiscountPolicy();

    @ParameterizedTest(name = "{1} mua {0} → trả {2}")
    @CsvSource({
        "500,   REGULAR, 500",     // dưới ngưỡng
        "1000,  REGULAR, 1000",    // đúng ngưỡng: chưa giảm
        "2000,  REGULAR, 1900",    // giảm 5%
        "100,   VIP,     90",
        "100,   STAFF,   70",
        "0,     VIP,     0"
    })
    void appliesDiscountByTier(long subtotal, CustomerTier tier, long expected) {
        assertThat(policy.apply(Money.of(subtotal), tier)).isEqualTo(Money.of(expected));
    }

    @Test
    @DisplayName("subtotal âm thì ném IllegalArgumentException")
    void rejectsNegativeSubtotal() {
        var ex = assertThrows(IllegalArgumentException.class,
                () -> policy.apply(Money.of(-1), CustomerTier.VIP));
        assertThat(ex).hasMessageContaining("subtotal");
        // AssertJ tương đương:
        // assertThatThrownBy(() -> ...).isInstanceOf(IllegalArgumentException.class)
    }

    @Nested
    @DisplayName("khách VIP")
    class Vip {
        @Test
        void neverPaysMoreThanNinetyPercent() {
            assertThat(policy.apply(Money.of(1_000), CustomerTier.VIP))
                .isLessThanOrEqualTo(Money.of(900));
        }
    }
}

AssertJ với object phức tạp:

assertThat(order)
    .extracting(Order::getStatus, Order::getQuantity)
    .containsExactly(OrderStatus.CREATED, 2);

assertThat(orders).hasSize(3)
    .extracting(Order::getCustomerId).containsOnly(42L);

assertAll(                                   // báo TẤT CẢ assert sai, không dừng ở cái đầu
    () -> assertThat(order.getTotal()).isEqualTo(Money.of(180)),
    () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED));

Bước 4: Chạy

./mvnw test -Dtest=DiscountPolicyTest
# Output:
# DiscountPolicy
#   ✔ REGULAR mua 1000 → trả 1000
#   ✔ VIP mua 100 → trả 90
#   khách VIP
#     ✔ neverPaysMoreThanNinetyPercent

5. Cách hoạt động: lifecycle mặc định PER_METHOD

JUnit tạo instance MỚI của test class cho MỖI @Test

@BeforeAll (static, 1 lần)
   ├─ new DiscountPolicyTest() → @BeforeEach → test1 → @AfterEach
   ├─ new DiscountPolicyTest() → @BeforeEach → test2 → @AfterEach
   └─ new DiscountPolicyTest() → @BeforeEach → test3 → @AfterEach
@AfterAll (static, 1 lần)

→ field instance KHÔNG bị chia sẻ giữa test (isolation)
→ field static thì CÓ chia sẻ → nguồn gốc test phụ thuộc thứ tự

@TestInstance(Lifecycle.PER_CLASS) dùng chung một instance (cho phép @BeforeAll không static), nhưng phải tự reset state.

6. So sánh

JUnit 4JUnit 5
Setup@Before, @BeforeClass@BeforeEach, @BeforeAll
Exception@Test(expected=...)assertThrows (trả về exception để assert tiếp)
Mở rộng@RunWith (chỉ 1), @Rule@ExtendWith (nhiều extension)
Tham sốRunner riêng, rườm rà@ParameterizedTest sẵn
NhómKhông@Nested, @DisplayName, @Tag
VisibilityPhải publicPackage-private được

7. Khi nào nên dùng / không nên

Test của mình chạy riêng từng cái thì xanh, chạy cả suite trên CI thì thỉnh thoảng đỏ. Có thể do đâu?

Đúng rồi, đó gần như luôn là lỗi test isolation: test A để lại "rác" mà test B dựa vào. Các nguồn hay gặp:

// Tìm thủ phạm: chạy ngẫu nhiên thứ tự
# src/test/resources/junit-platform.properties
junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random

Mỗi test phải tự tạo dữ liệu cần dùng và không phụ thuộc test khác. Rollback DB sau test có bẫy riêng, xem bài 5.6; xử lý flaky test có hệ thống ở bài 5.9.

Đã có assertEquals của JUnit, sao mọi người lại thích dùng AssertJ?

Chủ yếu vì đọc dễ và message lỗi rõ hơn.

assertEquals(3, orders.size());
// expected: <3> but was: <2>          ← không biết list có gì

assertThat(orders).hasSize(3);
// Expected size: 3 but was: 2 in:
// [Order(id=1, ...), Order(id=2, ...)]  ← in luôn nội dung

JUnit assertion vẫn ổn cho case đơn giản. Quan trọng là cả team thống nhất một kiểu.

Phỏng vấn hỏi JUnit 5 thì trả lời sao?

Q1. Lifecycle của một test class JUnit 5?

🇻🇳 Tiếng Việt:

Mặc định là PER_METHOD: JUnit tạo một instance mới của test class cho mỗi test method. @BeforeAll và @AfterAll là static, chạy một lần; @BeforeEach và @AfterEach chạy quanh mỗi test. Nhờ vậy field instance không bị chia sẻ, test cô lập với nhau. PER_CLASS thì dùng chung instance, phải tự reset state.

🇬🇧 English:

The default is PER_METHOD: JUnit creates a new test class instance for every test method. @BeforeAll and @AfterAll are static and run once, while @BeforeEach and @AfterEach run around every test. This keeps instance fields isolated between tests. With PER_CLASS the instance is shared, so you must reset state yourself.

Q2. JUnit 5 khác JUnit 4 điểm nào chính?

🇻🇳 Tiếng Việt:

JUnit 5 tách thành Platform, Jupiter và Vintage. Extension model với @ExtendWith thay cho @RunWith và @Rule, có thể dùng nhiều extension. Có sẵn @ParameterizedTest, @Nested, @DisplayName, assertThrows trả về exception, và test method không cần public.

🇬🇧 English:

JUnit 5 is split into Platform, Jupiter and Vintage. The @ExtendWith extension model replaces @RunWith and rules and allows multiple extensions. It has built-in @ParameterizedTest, @Nested, @DisplayName, assertThrows that returns the exception, and test methods no longer need to be public.

Q3. Test chạy riêng xanh, chạy cả suite đỏ, bạn xử lý thế nào?

🇻🇳 Tiếng Việt:

Đó là lỗi isolation. Em chạy với thứ tự ngẫu nhiên để tái hiện, rồi tìm state chung: field static, cache, dữ liệu DB không dọn, thời gian thật. Sửa bằng cách cho mỗi test tự chuẩn bị dữ liệu, inject Clock, dọn DB, không dựa vào thứ tự chạy.

🇬🇧 English:

That is an isolation problem. I run the tests in random order to reproduce it and look for shared state: static fields, caches, leftover database rows or the real clock. The fix is to make each test set up its own data, inject a Clock, clean the database and never rely on execution order.

Cách nhớ ngắn gọn


Tự kiểm tra

Một class có 3 @Test, JUnit tạo bao nhiêu instance mặc định?

3 (PER_METHOD).

Vì sao @BeforeAll phải là static?

Nó chạy trước khi có instance nào; chỉ bỏ static được khi dùng @TestInstance(PER_CLASS).

Annotation nào cho test chạy với nhiều dòng dữ liệu CSV?

@ParameterizedTest + @CsvSource (hoặc @CsvFileSource).

assertAll khác nhiều assert liên tiếp chỗ nào?

Chạy hết rồi báo tất cả lỗi, thay vì dừng ở assertion sai đầu tiên.

Test dùng LocalDate.now() có vấn đề gì?

Phụ thuộc thời gian thật, dễ flaky; inject Clock và dùng Clock.fixed.