Hai mươi bảy bài của khoá này đều kết thúc theo cùng một kiểu: đây là chương trình, đây là thứ nó in ra khi tôi chạy, và đây là lý do. Bản thân thói quen đó đã là một test rồi. Thứ còn thiếu là phần giúp nó sống sót qua lần sửa code tiếp theo — viết kết quả mong đợi ra thành code, để máy kiểm tra thay cho mắt bạn.
Bài này mở đầu Phần 6 của khoá học: testing và chất lượng code. JUnit 5 là điểm khởi đầu, vì đó là công cụ mà mọi project Java trong hệ sinh thái đều dùng, và vì phần lớn những thứ người ta thấy khó hiểu ở nó đều thuộc về cơ chế chứ không phải triết lý.
![]()
Mọi câu lệnh, transcript và message báo lỗi bên dưới đều được tạo ra trên OpenJDK 21.0.6 (arm64) với JUnit Jupiter 5.11.3 chạy qua JUnit Platform Console Launcher 1.11.3. Các dòng thời lượng chạy do launcher in ra đã được lược bỏ, vì chúng đo cái máy chứ không đo code.
Unit test thực sự dùng để làm gì
Unit test không chứng minh được code của bạn đúng. Nó không thể: nó chỉ chạy qua vài input trong số tất cả input mà method chấp nhận, và không nói được gì về phần còn lại. Ai bảo bạn rằng suite toàn màu xanh nghĩa là phần mềm chạy đúng thì người đó đang bán cho bạn thứ gì đó.
Thứ mà test làm được là ghim hành vi lại. Bạn quyết định add(2, 3) bằng 5, bạn viết điều đó ra dưới dạng máy kiểm tra lại được, và từ giây phút đó mọi thay đổi làm nó không còn bằng 5 nữa sẽ bị phát hiện sau vài giây thay vì sau một ticket của khách hàng. Đó là toàn bộ giá trị, và như vậy là đủ. Test là một bộ phát hiện thay đổi, không phải một chứng minh.
Từ đó suy ra hai hệ quả, và cả hai đều quan trọng hơn mọi chi tiết API trong bài này. Thứ nhất, một test chỉ đáng giá bằng đúng message báo lỗi của nó — một test fail với dòng "expected true, was false" bắt bạn trả lại đúng buổi debug mà nó lẽ ra phải tiết kiệm cho bạn. Thứ hai, một test không bao giờ fail được thì tệ hơn là không có test, vì nó tạo ra một dấu tick xanh mà không ai xem lại nữa. Cả hai đều quay lại ở cuối bài, kèm output thật.
JUnit 5 là ba project, không phải một
"JUnit 5" là tên gọi chung của ba mảnh tách rời, và biết mảnh nào là mảnh nào giúp bạn đỡ rối khi khai báo dependency:
| Mảnh | Nó là gì | Bạn động tới khi |
|---|---|---|
| JUnit Platform | Launcher và API discovery không phụ thuộc engine. Định nghĩa thế nào là một test engine và chạy mọi engine nó tìm thấy. | Bạn cấu hình build, IDE runner hoặc console launcher |
| JUnit Jupiter | Mô hình lập trình bạn thực sự viết: @Test, @BeforeEach, Assertions, @ParameterizedTest, cùng engine chạy chúng. | Bạn viết test |
| JUnit Vintage | Engine chạy test JUnit 3 và JUnit 4 trên Platform. | Bạn còn suite cũ chưa migrate |
Việc tách ra như vậy để những framework khác — Spock, Cucumber, Kotest — cắm engine riêng của họ vào cùng một Platform và được IDE cùng build tool nhận diện y hệt. Đây không phải chuyện lý thuyết: cái launcher dùng xuyên suốt bài này chính là Platform, và các engine nó tìm thấy được in ra trong mọi lần chạy.
Chạy test đầu tiên bằng console launcher
Project thật thì để build tool tải JUnit về và chạy suite; đó là phần của bài 31. Để bài này nói về JUnit chứ không phải về Maven, mọi thứ ở đây dùng console launcher dạng standalone — một file jar duy nhất chứa Platform, cả hai engine và toàn bộ Jupiter API.
curl -sSO https://repo1.maven.org/maven2/org/junit/platform/junit-platform-console-standalone/1.11.3/junit-platform-console-standalone-1.11.3.jarMột class cần test, và một test class nằm cạnh nó:
public class Calculator {
public int add(int a, int b) { return a + b; }
public int divide(int a, int b) { return a / b; }
}import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class CalculatorTest {
private final Calculator calc = new Calculator();
@Test
void addsTwoPositiveNumbers() {
assertEquals(5, calc.add(2, 3));
}
@Test
void addsNegativeNumbers() {
assertEquals(-5, calc.add(-2, -3));
}
@Test
void addIsNotSubtraction() {
assertEquals(5, calc.add(2, 2));
}
}Để ý thứ không có ở đây: không public trên class hay method, không main, không kế thừa từ base class nào. Jupiter tìm ra class và method package-private qua annotation, nên đống visibility rườm rà mà JUnit 4 bắt buộc đã biến mất.
Compile với jar rồi chạy class qua launcher:
javac -cp junit-platform-console-standalone-1.11.3.jar -d out src/Calculator.java src/CalculatorTest.java
java -jar junit-platform-console-standalone-1.11.3.jar execute -cp out \
--select-class=CalculatorTest --details=tree --disable-ansi-colors --disable-banner╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ CalculatorTest ✔
│ ├─ addIsNotSubtraction() ✘ expected: <5> but was: <4>
│ ├─ addsTwoPositiveNumbers() ✔
│ └─ addsNegativeNumbers() ✔
└─ JUnit Vintage ✔
Failures (1):
JUnit Jupiter:CalculatorTest:addIsNotSubtraction()
MethodSource [className = 'CalculatorTest', methodName = 'addIsNotSubtraction', methodParameterTypes = '']
=> org.opentest4j.AssertionFailedError: expected: <5> but was: <4>
org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150)
org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:145)
org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:531)
CalculatorTest.addIsNotSubtraction(CalculatorTest.java:21)
java.base/java.lang.reflect.Method.invoke(Method.java:580)
java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
[ 4 containers found ]
[ 0 containers skipped ]
[ 4 containers started ]
[ 0 containers aborted ]
[ 4 containers successful ]
[ 0 containers failed ]
[ 3 tests found ]
[ 0 tests skipped ]
[ 3 tests started ]
[ 0 tests aborted ]
[ 2 tests successful ]
[ 1 tests failed ]Có ba chỗ trong output đó đáng đọc kỹ. Ba engine ở trên cùng là Platform báo cáo thứ nó tìm được — Jupiter chạy test, Vintage không thấy gì, còn Suite engine không thấy class @Suite nào. Bốn container là ba engine đó cộng thêm CalculatorTest; container chứa test, còn test là các nút lá. Và stack trace đã được launcher cắt gọn về đúng những frame bạn cần, với CalculatorTest.java:21 chỉ thẳng ra dòng assertion.
Đổi --details=tree thành --details=summary để bỏ cây, --details=none để chỉ giữ lại System.out của chính bạn. Thay cho --select-class, bạn có thể truyền --select-package=com.example hoặc --scan-classpath để chạy tất cả những gì tìm thấy.
Lifecycle: cái gì chạy lúc nào, và có bao nhiêu instance
Bốn callback bao quanh test của bạn. @BeforeAll và @AfterAll chạy đúng một lần cho cả class và mặc định phải là static; @BeforeEach và @AfterEach chạy quanh từng test method một.

Mỗi test method một instance mới
Phần hiếm khi được dạy: Jupiter tạo một instance hoàn toàn mới của test class cho từng method @Test. In System.identityHashCode(this) ra là hết đường cãi.
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.ArrayList;
import java.util.List;
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 List<String> items = new ArrayList<>();
@BeforeAll
static void beforeAll() {
System.out.println("@BeforeAll (once, static)");
}
@BeforeEach
void beforeEach() {
System.out.println(" @BeforeEach instance=" + System.identityHashCode(this)
+ " items=" + items);
}
@Test
void firstTest() {
items.add("a");
System.out.println(" firstTest instance=" + System.identityHashCode(this)
+ " items=" + items);
assertEquals(1, items.size());
}
@Test
void secondTest() {
items.add("b");
System.out.println(" secondTest instance=" + System.identityHashCode(this)
+ " items=" + items);
assertEquals(1, items.size());
}
@Test
void thirdTest() {
items.add("c");
System.out.println(" thirdTest instance=" + System.identityHashCode(this)
+ " items=" + items);
assertEquals(1, items.size());
}
@AfterEach
void afterEach() {
System.out.println(" @AfterEach instance=" + System.identityHashCode(this));
}
@AfterAll
static void afterAll() {
System.out.println("@AfterAll (once, static)");
}
}@BeforeAll (once, static)
@BeforeEach instance=657736958 items=[]
thirdTest instance=657736958 items=[c]
@AfterEach instance=657736958
@BeforeEach instance=205721196 items=[]
firstTest instance=205721196 items=[a]
@AfterEach instance=205721196
@BeforeEach instance=51554940 items=[]
secondTest instance=51554940 items=[b]
@AfterEach instance=51554940
@AfterAll (once, static)Ba identity khác nhau, và items rỗng ở đầu mỗi lần dù test nào cũng thêm phần tử vào. Field không bị reset bởi đoạn cleanup nào cả — chính cái object chứa nó đã bị vứt đi và một object mới được tạo ra. Gán field ở test này thì không đời nào rò rỉ sang test kia, và đó đúng là lý do người ta chọn thiết kế này.
Bản thân các con số identity là giá trị nội bộ tuỳ ý của JVM và không mang ý nghĩa gì; điều đáng chú ý là chúng khác nhau đủ ba cái. Còn thứ tự thì có ý nghĩa: các method chạy theo thứ tự third, first, second, và chạy lại vẫn ra đúng thứ tự đó. Thứ tự mặc định của Jupiter là deterministic nhưng cố tình không được đặc tả — nó là một hash ổn định của method chứ không phải thứ tự trong file — chính là để không ai lỡ tay phụ thuộc vào nó.
@TestInstance(PER_CLASS) và cái giá phải trả
Một annotation là đủ để chuyển class sang dùng chung một instance:
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PerClassTest {
private final List<String> items = new ArrayList<>();
@BeforeAll
void beforeAll() {
System.out.println("@BeforeAll is no longer static, instance="
+ System.identityHashCode(this));
}
@Test
void firstTest() {
items.add("a");
System.out.println(" firstTest instance=" + System.identityHashCode(this)
+ " items=" + items);
}
@Test
void secondTest() {
items.add("b");
System.out.println(" secondTest instance=" + System.identityHashCode(this)
+ " items=" + items);
}
}@BeforeAll is no longer static, instance=764419760
firstTest instance=764419760 items=[a]
secondTest instance=764419760 items=[a, b]Bây giờ chỉ còn một identity, @BeforeAll không cần static nữa, và items mang a sang tận test thứ hai. Dòng cuối cùng chính là cái giá: với PER_CLASS bạn phải tự dọn dẹp, và bạn vừa biến test của mình thành phụ thuộc thứ tự trừ khi bạn viết một @AfterEach để trả lại nguyên trạng. Hãy dùng nó khi fixture thực sự đắt để dựng, hoặc khi bạn cần một @MethodSource không static, chứ không phải chỉ vì thấy static bất tiện.
Quên annotation này thì lỗi nổ ra ngay chứ không âm thầm, cũng là một sự tử tế:
=> org.junit.platform.commons.JUnitException: @BeforeAll method 'void NonStaticBeforeAllTest.setUp()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).Vì instance dù sao cũng được dựng lại mỗi lần, đa số fixture nên nằm trong field initializer hoặc @BeforeEach. Đây là một fixture có kèm collaborator, viết tay đúng một dòng vì nó chỉ có một method:
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class SubscriptionTest {
private Subscription subscription;
// a fake collaborator, hand-written: no library needed
private final Clock fixedClock = () -> LocalDate.of(2026, 9, 19);
@BeforeEach
void setUp() {
subscription = new Subscription(LocalDate.of(2026, 9, 30));
}
@Test
void isActiveBeforeTheExpiryDate() {
assertTrue(subscription.isActive(fixedClock));
}
@Test
void isInactiveAfterTheExpiryDate() {
Clock later = () -> LocalDate.of(2026, 10, 1);
assertFalse(subscription.isActive(later));
}
@Test
void isActiveOnTheExpiryDateItself() {
Clock onExpiry = () -> LocalDate.of(2026, 9, 30);
assertTrue(subscription.isActive(onExpiry));
}
}Cả ba đều pass, và không có framework test nào tham gia vào việc giả lập clock — Clock là interface một method nên một lambda là đủ. Khi collaborator lớn hơn thế, hoặc khi bạn cần kiểm tra nó đã được gọi như thế nào chứ không phải nó trả về gì, thì cần một thư viện làm cho tử tế; đó là bài 29.
Assertion, và vì sao dòng báo lỗi mới là thứ quan trọng
Mọi assertion đều là static method trên org.junit.jupiter.api.Assertions, và thứ tự tham số luôn là (expected, actual). Đảo ngược hai cái đó không làm test sai, nó làm message sai — còn tệ hơn.

assertEquals, assertTrue, assertNull
Ba cái cốt lõi, mỗi cái được cho fail có chủ đích để bạn đọc đúng thứ nó in ra:
├─ equalsInt() ✘ expected: <5> but was: <4>
├─ equalsString() ✘ expected: <hello world> but was: <hello wordl>
├─ trueFails() ✘ expected: <true> but was: <false>
├─ nullFails() ✘ expected: <null> but was: <21.0.6>
├─ arrayEqualsFails() ✘ array contents differ at index [1], expected: <2> but was: <9>
├─ arrayLengthFails() ✘ array lengths differ, expected: <3> but was: <2>assertEquals so sánh bằng equals(), nên nó dùng được với mọi type cài đặt method đó tử tế, và nó in ra cả hai giá trị. assertTrue là cái yếu nhất: một boolean thì chỉ có true và false để báo, nên nó luôn cần một message đi kèm. assertArrayEquals không phải là assertEquals áp lên array — cái sau sẽ so sánh reference — và nó còn chỉ ra index đầu tiên khác nhau.
assertSame không phải assertEquals
assertEquals hỏi "hai cái này có bằng nhau không", còn assertSame hỏi "hai cái này có phải cùng một object không". Hai String có nội dung giống hệt vẫn fail assertSame:
@Test
void sameFails() {
String a = "ja" + "va";
String b = new String("java");
assertSame(a, b);
}├─ sameFails() ✘ expected: java.lang.String@790da477<java> but was: java.lang.String@5c7933ad<java>Để ý cách message được dựng: có cả identity hash lẫn giá trị, chính vì "expected java but was java" thì vô dụng. Chỉ dùng assertSame khi identity đúng là thứ bạn đang khẳng định — một cache trả về đúng instance cũ, một singleton, một giá trị được intern — và không dùng cho gì khác.
assertThrows trả về chính exception đó
assertThrows fail nếu block không ném gì, hoặc ném sai type. Quan trọng hơn, nó trả về đúng exception nó bắt được, nên message cũng assert được luôn:
@Test
void divideByZeroReportsWhichOperation() {
Calculator calc = new Calculator();
ArithmeticException ex = assertThrows(
ArithmeticException.class,
() -> calc.divide(10, 0));
System.out.println("caught: " + ex);
assertEquals("/ by zero", ex.getMessage());
}caught: java.lang.ArithmeticException: / by zero
├─ divideByZeroReportsWhichOperation() ✔Cả hai kiểu fail đều được báo rõ ràng:
├─ throwsNothing() ✘ Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
├─ throwsWrongType() ✘ Unexpected exception type thrown, expected: <java.lang.IllegalStateException> but was: <java.lang.NumberFormatException>Như vậy đã tốt hơn hẳn một khối try/catch có fail() ở cuối, và tốt hơn rất nhiều so với try/catch không có fail() — kiểu code mà chúng ta sẽ gặp lại ở cuối bài.
assertAll: một failure không được che mất những cái còn lại
Assertion ném exception, nên method dừng ngay ở cái fail đầu tiên và mọi thứ phía sau không bao giờ chạy. assertAll chạy hết mọi block được truyền vào và báo tất cả failure cùng lúc. Vẫn ba phép kiểm tra đó, viết theo hai cách:
@Test
void plainAssertions() {
assertEquals("hoang", name);
assertTrue(name.length() > 0, "name must not be empty");
assertEquals(3, name.length());
}
@Test
void grouped() {
assertAll("user",
() -> assertEquals("hoang", name),
() -> assertTrue(name.length() > 0, "name must not be empty"),
() -> assertEquals(3, name.length()));
}├─ plainAssertions() ✘ expected: <hoang> but was: <>
└─ grouped() ✘ user (3 failures)
org.opentest4j.AssertionFailedError: expected: <hoang> but was: <>
org.opentest4j.AssertionFailedError: name must not be empty ==> expected: <true> but was: <false>
org.opentest4j.AssertionFailedError: expected: <3> but was: <0>Một vòng sửa rồi chạy lại, thay vì ba. Hãy dùng nó khi bạn kiểm tra nhiều thuộc tính của cùng một kết quả — các field của một object vừa parse xong, các thành phần của một record được trả về — chứ không phải để ghép các test chẳng liên quan gì vào chung một chỗ.
assertTimeout và các overload nhận message supplier
Mọi assertion đều có một overload nhận message, và một overload nữa nhận Supplier sinh ra message. Dạng supplier tồn tại để một message tốn kém chỉ được dựng khi test thực sự fail:
@Test
void equalsWithMessage() {
assertEquals(5, 2 + 2, "add() is broken");
}
@Test
void equalsWithSupplier() {
List<String> input = List.of("a", "b");
assertEquals(3, input.size(), () -> "wrong size for input " + input);
}├─ equalsWithMessage() ✘ add() is broken ==> expected: <5> but was: <4>
├─ equalsWithSupplier() ✘ wrong size for input [a, b] ==> expected: <3> but was: <2>Dấu ==> là chỗ JUnit nối message của bạn với message của nó. Nửa của bạn nói invariant nào bị vi phạm; nửa của JUnit nói vi phạm với giá trị nào.
assertTimeout chạy một block, để nó chạy xong, rồi mới fail nếu nó vượt quá ngân sách thời gian. Nó cũng trả về đúng thứ block trả về:
@Test
void slugifyingIsNotSlow() {
String slug = assertTimeout(Duration.ofSeconds(1), () -> Text.slug("Hello World"));
assertEquals("hello-world", slug);
}Khi nó fail thật, message nêu ra cả ngân sách lẫn phần vượt. Khối dưới đây ngủ 300 ms với ngân sách 50 ms, nên phần vượt do chính đoạn code quyết định chứ không phải do máy — chạy năm lần cho ra từ 251 tới 255 ms:
execution exceeded timeout of 50 ms by 254 msassertTimeoutPreemptively là biến thể cắt ngang block trên một thread riêng ngay khi hết ngân sách, thay vì đợi nó chạy xong. Hãy dùng timeout để bắt treo, đừng bao giờ dùng để assert hiệu năng — một máy CI dùng chung sẽ làm test đó flaky, và con số bạn viết ra là config bạn chọn chứ không phải kết quả đo được của bất cứ thứ gì.
| Assertion | Fail khi | Dùng cho |
|---|---|---|
assertEquals(exp, act) | equals() trả về false | Giá trị của mọi type |
assertNotEquals | Hai giá trị bằng nhau | Hiếm dùng, và thường là test yếu |
assertTrue / assertFalse | Boolean ngược với mong đợi | Điều kiện, luôn kèm message |
assertNull / assertNotNull | Tính null khác nhau | Sự vắng mặt, không phải sự rỗng |
assertSame / assertNotSame | Reference identity khác nhau | Cache, singleton, giá trị intern |
assertArrayEquals | Độ dài hoặc phần tử khác nhau | Array, so từng phần tử |
assertIterableEquals | Kích thước hoặc phần tử khác nhau | List và các iterable khác |
assertThrows | Không ném gì, hoặc sai type | Nhánh lỗi, rồi assert tiếp message |
assertDoesNotThrow | Có bất cứ thứ gì bị ném ra | Chứng minh một crash đã được sửa |
assertAll | Bất kỳ block nào trong nhóm fail | Nhiều thuộc tính của cùng một kết quả |
assertTimeout | Block vượt ngân sách thời gian | Bắt treo |
fail() | Luôn luôn | Đánh dấu một nhánh lẽ ra không tới được |
Đặt tên và nhóm test: @DisplayName, @Nested, @Disabled, @Tag
Tên method thì gọn nhưng xấu; @DisplayName cho report một câu đọc được. @Nested gom các test của một method hoặc một kịch bản vào một inner class, để report trở thành bản outline của hành vi. @Disabled bỏ qua kèm lý do. @Tag gắn nhãn cho test để chọn hoặc loại nó ra khỏi một lần chạy.
@DisplayName("Calculator")
class OrganisationTest {
private final Calculator calc = new Calculator();
@Nested
@DisplayName("add()")
class Add {
@Test
@DisplayName("returns the sum of two positive numbers")
void positives() {
assertEquals(5, calc.add(2, 3));
}
@Test
@DisplayName("is commutative")
void commutative() {
assertEquals(calc.add(2, 3), calc.add(3, 2));
}
}
@Nested
@DisplayName("divide()")
class Divide {
@Test
@DisplayName("throws ArithmeticException on a zero divisor")
void byZero() {
assertThrows(ArithmeticException.class, () -> calc.divide(1, 0));
}
@Test
@DisplayName("truncates towards zero")
@Disabled("integer division rounding is not decided yet")
void truncation() {
assertEquals(-2, calc.divide(-7, 3));
}
}
@Test
@Tag("slow")
@DisplayName("survives a large batch of additions")
void largeBatch() {
int sum = 0;
for (int i = 0; i < 1_000_000; i++) {
sum = calc.add(sum, 1);
}
assertEquals(1_000_000, sum);
}
@Test
@Tag("fast")
@DisplayName("adds zero without changing the value")
void addZero() {
assertTrue(calc.add(7, 0) == 7);
}
}├─ JUnit Jupiter ✔
│ └─ Calculator ✔
│ ├─ adds zero without changing the value ✔
│ ├─ survives a large batch of additions ✔
│ ├─ divide() ✔
│ │ ├─ truncates towards zero ↷ integer division rounding is not decided yet
│ │ └─ throws ArithmeticException on a zero divisor ✔
│ └─ add() ✔
│ ├─ is commutative ✔
│ └─ returns the sum of two positive numbers ✔Ký hiệu ↷ đánh dấu chỗ bị bỏ qua, và chuỗi lý do bạn đưa cho @Disabled được in ngay bên cạnh — đó chính là khác biệt giữa một test bị tắt mà ai đó sẽ quay lại xử lý và một test bị tắt rồi mục ruỗng trong im lặng. Một nested class là inner class bình thường, nên nó có @BeforeEach và field riêng, và nó nhìn thấy instance bên ngoài.
Chạy một tập con theo tag
Tag được so khớp bởi Platform chứ không phải bởi Jupiter, nên cùng một biểu thức dùng được ở mọi runner. Với console launcher:
java -jar junit-platform-console-standalone-1.11.3.jar execute -cp out \
--select-class=OrganisationTest --include-tag=fast --details=tree├─ JUnit Jupiter ✔
│ └─ Calculator ✔
│ └─ adds zero without changing the value ✔--exclude-tag=slow là chiều ngược lại và giữ lại tất cả phần còn lại, kể cả test bị disable. Cả hai cờ đều nhận biểu thức tag, nên --include-tag='fast | slow' và --include-tag='fast & !slow' chạy đúng như viết. Quy ước thường gặp là gắn tag cho phần thiểu số — những test chậm hoặc phụ thuộc bên ngoài — và để phần đa số chạy nhanh không tag gì cả.
Parameterized test: một method, nhiều case
Một vòng lặp bên trong @Test sẽ dừng ở giá trị hỏng đầu tiên và chỉ báo đúng một failure. @ParameterizedTest giao vòng lặp đó cho engine, và mỗi case trở thành một test riêng với tên riêng và kết quả riêng.

Năm nguồn argument
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.stream.Stream;
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.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
class ParameterizedDemoTest {
@ParameterizedTest
@ValueSource(ints = {2, 3, 5, 7, 11})
void primesAreOddOrTwo(int n) {
assertTrue(n == 2 || n % 2 == 1);
}
@ParameterizedTest(name = "slug({0}) is {1}")
@CsvSource({
"'Hello World', hello-world",
"' Spaced out ', spaced-out",
"'JUnit 5!', junit-5"
})
void slugifies(String input, String expected) {
assertEquals(expected, Text.slug(input));
}
@ParameterizedTest
@EnumSource(Priority.class)
void everyPriorityHasAPositiveWeight(Priority p) {
assertTrue(p.weight() > 0);
}
@ParameterizedTest
@EnumSource(value = Priority.class, names = {"LOW", "MEDIUM"})
void lowAndMediumAreUnderNine(Priority p) {
assertTrue(p.weight() < 9);
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", " "})
void blankInputsAreBlank(String input) {
assertTrue(Text.isBlank(input));
}
@ParameterizedTest
@MethodSource("slugCases")
void slugifiesFromMethodSource(String input, String expected) {
assertEquals(expected, Text.slug(input));
}
static Stream<Arguments> slugCases() {
return Stream.of(
Arguments.of("Advanced Java", "advanced-java"),
Arguments.of("JUnit 5 & Mockito", "junit-5-mockito"),
Arguments.of("---dashes---", "dashes"));
}
}│ └─ ParameterizedDemoTest ✔
│ ├─ lowAndMediumAreUnderNine(Priority) ✔
│ │ ├─ [1] LOW ✔
│ │ └─ [2] MEDIUM ✔
│ ├─ slugifiesFromMethodSource(String, String) ✔
│ │ ├─ [1] Advanced Java, advanced-java ✔
│ │ ├─ [2] JUnit 5 & Mockito, junit-5-mockito ✔
│ │ └─ [3] ---dashes---, dashes ✔
│ ├─ everyPriorityHasAPositiveWeight(Priority) ✔
│ │ ├─ [1] LOW ✔
│ │ ├─ [2] MEDIUM ✔
│ │ └─ [3] HIGH ✔
│ ├─ primesAreOddOrTwo(int) ✔
│ │ ├─ [1] 2 ✔
│ │ ├─ [2] 3 ✔
│ │ ├─ [3] 5 ✔
│ │ ├─ [4] 7 ✔
│ │ └─ [5] 11 ✔
│ ├─ blankInputsAreBlank(String) ✔
│ │ ├─ [1] null ✔
│ │ ├─ [2] ✔
│ │ ├─ [3] ✔
│ │ └─ [4] ✔
│ └─ slugifies(String, String) ✔
│ ├─ slug(Hello World) is hello-world ✔
│ ├─ slug( Spaced out ) is spaced-out ✔
│ └─ slug(JUnit 5!) is junit-5 ✔Sáu method biến thành hai mươi test, và mỗi case là một nút lá bạn chạy lại riêng được. Tên mặc định là chỉ số cộng với danh sách argument; name = "slug({0}) is {1}" thay nó bằng tên của bạn, trong đó {0} và {1} là các argument theo thứ tự. Mấy dòng của blankInputsAreBlank cho thấy mặt trái: argument toàn khoảng trắng sinh ra một display name đúng về mặt kỹ thuật nhưng vô dụng khi nhìn, và đó là lý do tốt để tự đặt name khi input của bạn vô hình.
Để ý @NullAndEmptySource được xếp chồng lên @ValueSource — các source cộng dồn với nhau, và các case đơn giản là được nối lại. Xếp @NullSource lên một parameter kiểu primitive thì vẫn compile được, rồi fail đúng case đó lúc runtime với Cannot convert null to primitive value of type int, tức là engine đang nói cho bạn biết source và signature không khớp nhau.
Khi một case đơn lẻ fail — ở đây vẫn là method slugifies đó nhưng chạy trên một bảng dữ liệu khác, trong đó một dòng mong đợi sai thành cpp — report gọi đúng tên dòng đó và để phần còn lại xanh:
│ └─ FailingCaseTest ✔
│ └─ slugifies(String, String) ✔
│ ├─ [1] Hello World, hello-world ✔
│ ├─ [2] Ha Noi 2026, ha-noi-2026 ✔
│ ├─ [3] C++, cpp ✘ expected: <cpp> but was: <c>
│ └─ [4] , ✔| Source | Cung cấp | Ghi chú |
|---|---|---|
@ValueSource | Một primitive, String hoặc Class cho mỗi case | Chỉ dùng cho method một argument |
@CsvSource | Nhiều argument mỗi case, viết trực tiếp | Bọc bằng ' để giữ khoảng trắng hoặc dấu phẩy |
@CsvFileSource | Cũng vậy, nhưng đọc từ resource trên classpath | Cho bảng dữ liệu quá lớn để viết inline |
@EnumSource | Các hằng enum | Lọc bằng names và mode |
@MethodSource | Bất cứ thứ gì một static method dựng được | Trả về Stream, Collection hoặc array |
@NullSource / @EmptySource / @NullAndEmptySource | Những input khó chịu | Xếp chồng lên một source khác |
Assumption: skip thay vì fail
Assertion nói "điều này phải đúng". Assumption nói "nếu điều này không đúng thì ở đây chẳng có gì để test cả". Một assumption fail sẽ abort test chứ không làm nó fail — code đang được test không bị buộc tội gì.
@Test
void driveLettersOnWindows() {
assumeTrue(File.separatorChar == '\\', "Windows-only test");
assertTrue(new File("C:\\").isAbsolute());
}
@Test
void needsAnEnvironmentVariable() {
String url = System.getenv("DATABASE_URL");
assumeTrue(url != null, "DATABASE_URL is not set");
assertTrue(url.startsWith("jdbc:"));
}
@Test
void assertingInsteadOfAssuming() {
assertTrue(System.getenv("DATABASE_URL") != null, "DATABASE_URL is not set");
}│ └─ AssumptionsDemoTest ✔
│ ├─ pathSeparatorIsSlashOnUnix() ✔
│ ├─ assertingInsteadOfAssuming() ✘ DATABASE_URL is not set ==> expected: <true> but was: <false>
│ ├─ driveLettersOnWindows() ■ Assumption failed: Windows-only test
│ └─ needsAnEnvironmentVariable() ■ Assumption failed: DATABASE_URL is not setHai test cuối được báo là aborted, và bộ đếm tách chúng ra khỏi cả failure thật lẫn skip do @Disabled:
[ 4 tests found ]
[ 0 tests skipped ]
[ 4 tests started ]
[ 2 tests aborted ]
[ 1 tests successful ]
[ 1 tests failed ]skipped đếm @Disabled, aborted đếm assumption fail, failed đếm những vấn đề thật. Cách dùng assumption tử tế là cho một điều kiện môi trường bạn không kiểm soát được — một hệ điều hành, một service có thể chưa chạy, một file fixture có thể chưa tồn tại. Cách dùng không tử tế là bịt miệng một test đang fail vì lý do thật, và nó không tử tế chính vì lần chạy vẫn kết thúc màu xanh.
Nếu đã có sẵn condition dạng khai báo thì hãy ưu tiên nó — @EnabledOnOs(WINDOWS), @EnabledIfEnvironmentVariable, @EnabledIfSystemProperty — vì chúng bỏ qua test trước cả khi thân method bắt đầu chạy và nói rõ lý do ngay trên annotation.
Điều gì làm một test trở nên vô dụng
Mọi thứ ở trên là cơ chế. Phần này mới là thứ quyết định một suite có đáng với chi phí bảo trì của nó hay không.
Dùng chung mutable state và phụ thuộc thứ tự
Cơ chế mỗi method một instance của Jupiter bảo vệ các field thông thường. Một field static thì nằm hoàn toàn ngoài lớp bảo vệ đó, và ngay khi hai test dùng chung một field như vậy, kết quả phụ thuộc vào cái nào chạy trước. Hai class dưới đây giống nhau đến từng byte trừ đúng một annotation:
class SharedStateTest {
private static final List<String> cart = new ArrayList<>();
@Test
void addingAnItemMakesTheCartSizeOne() {
cart.add("book");
assertEquals(1, cart.size());
}
@Test
void aNewCartIsEmpty() {
assertTrue(cart.isEmpty(), "cart should be empty, was " + cart);
}
}│ └─ SharedStateTest ✔
│ ├─ addingAnItemMakesTheCartSizeOne() ✔
│ └─ aNewCartIsEmpty() ✘ cart should be empty, was [book] ==> expected: <true> but was: <false>│ └─ SharedStateOrderedTest ✔
│ ├─ aNewCartIsEmpty() ✔
│ └─ addingAnItemMakesTheCartSizeOne() ✔Lần chạy thứ hai chỉ thêm @TestMethodOrder(MethodOrderer.MethodName.class) chứ không sửa gì khác. Thứ tự alphabet tình cờ đặt phép kiểm tra giỏ hàng rỗng lên trước, nên suite chuyển sang màu xanh — trong khi bug vẫn còn nguyên. Đó là giá trị thật của một test phụ thuộc thứ tự: nó báo cáo lịch chạy, không báo cáo code.
@TestMethodOrder chính đáng trong một số ít trường hợp — một kịch bản mang tính tài liệu, hoặc MethodOrderer.Random dùng có chủ đích để lôi ra những chỗ dính vào nhau. Nó không phải cách sửa cho shared state. Cách sửa là đừng dùng chung, hoặc reset lại trong @AfterEach.
Test không có assertion nào
Cả hai test này đều pass, và không cái nào có thể fail:
@Test
void emptyBody() {
}
@Test
void divideByZeroThrows() {
try {
calc.divide(10, 2);
} catch (ArithmeticException e) {
// expected
}
}│ ├─ divideByZeroThrows() ✔
│ ├─ emptyBody() ✔Cái thứ hai mới nguy hiểm, vì nó trông giống một test thật. Tên nó khẳng định chia cho 0 sẽ ném exception; nó chia cho 2, không bắt được gì, và vẫn pass. Kể cả khi truyền đúng tham số thì nó vẫn pass dù exception có tới hay không, vì chẳng có gì kiểm tra rằng khối catch đã được chạy vào. assertThrows tồn tại chính là để không ai phải viết kiểu code này nữa.
Bất kỳ test nào mà thân method không có assertion nào — cũng không có assertThrows, không có assertDoesNotThrow — thì chỉ đang khẳng định rằng code của bạn không nổ. Đôi khi đó đúng là thứ bạn muốn, và khi ấy assertDoesNotThrow nói thẳng điều đó ra.
Assert vào những thứ không thuộc contract
Một test nên assert đúng hành vi bạn đã hứa, chứ không phải những chi tiết tình cờ đi kèm:
@Test
void assertingOnIncidentalOutput() {
Set<String> tags = new HashSet<>(List.of("java", "junit", "testing"));
assertEquals("[java, junit, testing]", tags.toString());
}├─ assertingOnIncidentalOutput() ✘ expected: <[java, junit, testing]> but was: <[junit, java, testing]>Chẳng có gì hỏng cả. HashSet chưa bao giờ hứa về thứ tự duyệt, và code đang được test chưa bao giờ hứa về định dạng toString. Hãy assert vào contract — assertTrue(tags.contains("java")), hoặc assertEquals(3, tags.size()) — thì test sẽ thôi vỡ vì những thay đổi vốn không phải bug. Điều tương tự áp dụng cho dòng log, câu chữ trong message của exception không thuộc về bạn, các chữ số của số thực, và timestamp.
Test framework thay vì test code của bạn
@Test
void testingTheJdkInsteadOfYourCode() {
assertEquals(4, 2 + 2);
assertEquals("JAVA", "java".toUpperCase());
}Nó pass, nó sẽ luôn pass, và nó kiểm tra JDK chứ không kiểm tra thứ gì bạn viết ra. Cùng bản năng đó đẻ ra test cho getter và setter được sinh tự động, test cho một annotation của framework làm đúng thứ framework đã ghi trong tài liệu, và test cho một thư viện mapping thực hiện mapping. Chúng tốn công bảo trì mà không mua lại được gì. Hãy dồn công sức vào nhánh mà code của bạn thực sự quyết định, vào các giá trị biên, và vào nhánh lỗi — những chỗ mà một thay đổi có thể âm thầm mang ý nghĩa khác đi.
⚠️ Con số coverage đo xem dòng nào đã chạy, không đo xem hành vi nào đã được ghim. Một suite đầy test không có assertion vẫn đạt được con số rất cao mà chẳng phát hiện ra gì.
FAQ
Project nhỏ thế này có cần viết test không?
Kích thước project là câu hỏi sai; số lần bạn sẽ sửa nó mới là câu hỏi đúng. Code viết một lần rồi không đụng tới nữa thì suite test mang lại rất ít. Code mà tháng sau bạn sẽ sửa, sau khi đã quên nó chạy thế nào, mới là chỗ test có lãi — đó là cơ chế duy nhất báo cho bạn biết lần sửa vừa rồi làm hỏng cái gì, mà không cần bạn chạy tay lại chương trình rồi cố nhớ xem output cũ trông ra sao. Khoá học này đã chạy tay suốt hai mươi bảy bài; một test chính là thói quen đó, được viết lại thành code.
JUnit Platform, Jupiter và Vintage khác nhau ở đâu?
Platform là launcher và API cho engine — nó tìm và chạy test nhưng không định nghĩa test nào cả. Jupiter là mô hình lập trình của JUnit 5 cộng với engine của nó: mọi thứ có import org.junit.jupiter. Vintage là engine chạy test JUnit 3 và JUnit 4 cũ trên Platform, để suite cũ vẫn chạy được trong khi test mới được viết bằng Jupiter. Console launcher trong bài này chính là Platform, và nó in ra cả ba engine trong mọi lần chạy.
Vì sao field của tôi bị reset giữa các test?
Vì đó là một object khác. Mặc định Jupiter tạo một instance mới của test class cho từng method @Test, nên giá trị field không bao giờ được mang sang. Đó là tính năng chứ không phải lỗi: nó khiến các test độc lập với nhau mà không ai phải viết code dọn dẹp. Nếu bạn thực sự cần state sống sót, hãy gắn @TestInstance(TestInstance.Lifecycle.PER_CLASS) lên class và chấp nhận rằng việc reset nó giữa các test giờ là trách nhiệm của bạn.
Tôi có thể dựa vào thứ tự chạy của test không?
Không, và bạn nên viết test sao cho câu hỏi này không bao giờ xuất hiện. Thứ tự mặc định của Jupiter là deterministic — chạy lại vẫn ra đúng thứ tự đó — nhưng nó cố tình không phải thứ tự trong file và không nằm trong contract, nên nó có thể đổi giữa các phiên bản. Nếu bạn thực sự cần một thứ tự, hãy yêu cầu rõ ràng bằng @TestMethodOrder. Nếu bạn cần nó vì các test dùng chung mutable state, hãy sửa chỗ dùng chung đó.
Khi nào nên dùng assumeTrue thay cho assertTrue?
Dùng assumeTrue khi điều kiện nói về môi trường chứ không nói về code: sai hệ điều hành, thiếu một service, thiếu một file cấu hình. Test khi đó bị abort, và report phân biệt nó với một failure. Dùng assertTrue khi điều kiện là một khẳng định về code của bạn. Đừng bao giờ dùng assumption để giấu một test đang fail vì lý do thật — lần chạy vẫn xanh và bug vẫn được ship đi.
Làm sao để test rằng một method có ném exception?
assertThrows(SomeException.class, () -> code()). Nó fail nếu không có gì được ném ra và fail nếu ném sai type, và nó trả về đúng exception bắt được để bạn assert tiếp message hoặc cause. Đừng viết try/catch với khối catch rỗng: test kiểu đó pass bất kể exception có tới hay không, tức là còn tệ hơn việc không có test.
Coverage cao có phải là mục tiêu không?
Không. Coverage đo xem dòng nào đã được thực thi trong lần chạy, và đó không phải là hành vi nào đã được kiểm tra — một test không có assertion nào vẫn phủ hết những dòng nó đi qua mà chẳng phát hiện được gì. Coverage chỉ hữu ích theo đúng một chiều: một nhánh chưa được phủ thì chắc chắn chưa được test, nên nó là công cụ tốt để tìm lỗ hổng. Còn đặt nó làm chỉ tiêu thì bạn đang thưởng cho việc viết test chạy qua code, thay vì test ghim hành vi.
Kết luận
JUnit 5 là một API nhỏ xoay quanh đúng một ý: viết ra thứ bạn mong đợi, rồi để máy phát hiện khi nó không còn đúng nữa. Gần như mọi thứ còn lại đều suy ra từ đó. Assertion đáng dùng hơn câu if là vì message nó dựng ra. assertAll tồn tại để một mong đợi bị vỡ không che mất cái tiếp theo. @ParameterizedTest tồn tại để một input hỏng có tên riêng trong report, thay vì chỉ là giá trị mà vòng lặp tình cờ chết ở đó.
Chi tiết cơ chế đáng mang theo nhất là chi tiết người ta thường gặp một cách tình cờ: mỗi test method một instance mới của test class, đó là thứ khiến test độc lập ngay từ thiết kế và cũng là thứ @TestInstance(PER_CLASS) đánh đổi đi. Mọi thứ khác trong bài — fixture đặt trong @BeforeEach, không dùng chung static state, không phụ thuộc thứ tự — đều là hệ quả của việc giữ nguyên sự độc lập đó.
Thứ còn thiếu là bài toán collaborator. Clock trong bài này được giả lập bằng một lambda vì nó chỉ có một method; còn một repository nói chuyện với database, một client gọi HTTP API, hay một service mà bạn cần kiểm chứng đã được gọi với đúng tham số nào thì cần nhiều hơn thế. Bài 29 nói về Mockito, và cùng với nó là mock, stub và câu hỏi một test double nên và không nên giả vờ tới đâu.