JUnit 5 và AssertJ
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 đề
- Copy-paste 10 method test giống nhau, chỉ khác số → khó đọc, sửa logic phải sửa 10 chỗ.
assertEquals(expected, actual)hay bị đảo tham số, message lỗi khó hiểu.- Test exception bằng
try/catch + fail()dài và dễ quênfail(). - Dùng field
staticchung giữa các test → test chạy riêng thì xanh, chạy cả suite thì đỏ.
3. Giải pháp: các thành phần JUnit 5
- JUnit Platform (launcher cho IDE/Maven) + Jupiter (API
@Testmới) + Vintage (chạy test JUnit 4 cũ). - Lifecycle:
@BeforeAll,@BeforeEach,@AfterEach,@AfterAll. - Assertion:
assertThrows,assertAll; khuyên dùng AssertJ (assertThat(...).isEqualTo) để đọc như câu văn. - Tổ chức:
@DisplayName,@Nested,@Tag,@Disabled. - Data-driven:
@ParameterizedTestvới@ValueSource,@CsvSource,@EnumSource,@MethodSource.
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 4 | JUnit 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óm | Không | @Nested, @DisplayName, @Tag |
| Visibility | Phải public | Package-private được |
7. Khi nào nên dùng / không nên
@ParameterizedTest: cùng một hành vi, khác dữ liệu. Không dùng khi mỗi case cần setup/assert khác nhau, viết test riêng dễ đọc hơn.@Nested: nhóm theo ngữ cảnh ("khi hết hàng", "khi payment fail"). Đừng lồng quá 2 tầng.@BeforeAll: chỉ cho tài nguyên đắt và không đổi state (container, file mẫu).- Không dùng
@TestMethodOrderđể ép test chạy theo thứ tự cho test phụ thuộc nhau: đó là dấu hiệu thiếu isolation.
DiscountPolicy dễ vì không có dependency. Còn OrderService gọi PaymentClient ra mạng, test unit thế nào? Cần thay dependency bằng test double: Mockito.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:
- Field static / singleton bị test trước sửa (cache in-memory,
Clocktĩnh, feature flag). - Dữ liệu DB test trước insert không xóa; test sau đếm
count()ra sai. - Thời gian thật:
LocalDate.now()qua nửa đêm. InjectClockvà dùngClock.fixed(...)trong test. - Thứ tự: JUnit 5 không đảm bảo thứ tự method, đổi phiên bản là lộ.
- Chạy song song (
junit.jupiter.execution.parallel.enabled) mà dùng chung state.
// 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
- Fluent API: IDE gợi ý assertion phù hợp với kiểu (
String,List,Optional, exception). - Không nhầm thứ tự
expected, actual. - Có
extracting,usingRecursiveComparison()để so object nhiều field mà không cầnequals.
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
- Mỗi test một instance mới → field instance an toàn, static thì không.
- Cùng hành vi, khác dữ liệu →
@ParameterizedTest+@CsvSource. - Exception →
assertThrows/assertThatThrownBy. - Ngữ cảnh →
@Nested+@DisplayName. - Đỏ khi chạy cả suite → tìm state chung.
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.