Command Palette

Search for a command to run...

[Advanced Java] Unit Testing trong Java với JUnit 5

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

Ba thanh kết quả test, hai xanh và một đỏ mang theo giá trị mong đợi và giá trị nhận được

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ảnhNó là gìBạn động tới khi
JUnit PlatformLauncher 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 JupiterMô 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 VintageEngine 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.

Bash
curl -sSO https://repo1.maven.org/maven2/org/junit/platform/junit-platform-console-standalone/1.11.3/junit-platform-console-standalone-1.11.3.jar

Một class cần test, và một test class nằm cạnh nó:

Java
public class Calculator {
    public int add(int a, int b) { return a + b; }
    public int divide(int a, int b) { return a / b; }
}
Java
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:

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

├─ 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@AfterAll chạy đúng một lần cho cả class và mặc định phải là static; @BeforeEach@AfterEach chạy quanh từng test method một.

Một test class với một instance mới được tạo cho mỗi test method, đặt cạnh chính class đó khi chạy PER_CLASS

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.

Java
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)");
    }
}
Text
@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:

Java
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);
    }
}
Text
@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ế:

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

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

Ba assertion chạy rời so với khi được gom trong assertAll, kèm cấu trúc của một dòng báo lỗi

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:

Text
├─ 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ó truefalse để 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:

Java
@Test
void sameFails() {
    String a = "ja" + "va";
    String b = new String("java");
    assertSame(a, b);
}
Text
├─ 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:

Java
@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());
}
Text
caught: java.lang.ArithmeticException: / by zero
├─ divideByZeroReportsWhichOperation() ✔

Cả hai kiểu fail đều được báo rõ ràng:

Text
├─ 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/catchfail() ở 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:

Java
@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()));
}
Text
├─ 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:

Java
@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);
}
Text
├─ 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ề:

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

Text
execution exceeded timeout of 50 ms by 254 ms

assertTimeoutPreemptively 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ì.

AssertionFail khiDùng cho
assertEquals(exp, act)equals() trả về falseGiá trị của mọi type
assertNotEqualsHai giá trị bằng nhauHiếm dùng, và thường là test yếu
assertTrue / assertFalseBoolean ngược với mong đợiĐiều kiện, luôn kèm message
assertNull / assertNotNullTính null khác nhauSự vắng mặt, không phải sự rỗng
assertSame / assertNotSameReference identity khác nhauCache, singleton, giá trị intern
assertArrayEqualsĐộ dài hoặc phần tử khác nhauArray, so từng phần tử
assertIterableEqualsKích thước hoặc phần tử khác nhauList và các iterable khác
assertThrowsKhông ném gì, hoặc sai typeNhánh lỗi, rồi assert tiếp message
assertDoesNotThrowCó bất cứ thứ gì bị ném raChứng minh một crash đã được sửa
assertAllBất kỳ block nào trong nhóm failNhiều thuộc tính của cùng một kết quả
assertTimeoutBlock vượt ngân sách thời gianBắ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.

Java
@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);
    }
}
Text
├─ 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:

Bash
java -jar junit-platform-console-standalone-1.11.3.jar execute -cp out \
  --select-class=OrganisationTest --include-tag=fast --details=tree
Text
├─ 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'--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.

Một parameterized method với CSV source trải ra thành bốn test case được báo cáo độc lập

Năm nguồn argument

Java
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"));
    }
}
Text
│  └─ 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}{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:

Text
│  └─ 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]   ,  ✔
SourceCung cấpGhi chú
@ValueSourceMột primitive, String hoặc Class cho mỗi caseChỉ dùng cho method một argument
@CsvSourceNhiều argument mỗi case, viết trực tiếpBọc bằng ' để giữ khoảng trắng hoặc dấu phẩy
@CsvFileSourceCũng vậy, nhưng đọc từ resource trên classpathCho bảng dữ liệu quá lớn để viết inline
@EnumSourceCác hằng enumLọc bằng namesmode
@MethodSourceBất cứ thứ gì một static method dựng đượcTrả về Stream, Collection hoặc array
@NullSource / @EmptySource / @NullAndEmptySourceNhững input khó chịuXế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ì.

Java
@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");
}
Text
│  └─ 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 set

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

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

Java
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);
    }
}
Text
│  └─ SharedStateTest ✔
│     ├─ addingAnItemMakesTheCartSizeOne() ✔
│     └─ aNewCartIsEmpty() ✘ cart should be empty, was [book] ==> expected: <true> but was: <false>
Text
│  └─ 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:

Java
@Test
void emptyBody() {
}
 
@Test
void divideByZeroThrows() {
    try {
        calc.divide(10, 2);
    } catch (ArithmeticException e) {
        // expected
    }
}
Text
│     ├─ 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:

Java
@Test
void assertingOnIncidentalOutput() {
    Set<String> tags = new HashSet<>(List.of("java", "junit", "testing"));
    assertEquals("[java, junit, testing]", tags.toString());
}
Text
├─ 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

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

Bài viết liên quan

[Advanced Java] Debug và Logging trong Java với SLF4J, Logback và Log4j 2

Logging và debug trong Java trên OpenJDK 21: vì sao SLF4J chỉ là API không có implementation, chuyện gì xảy ra với 0, 1 và 2 binding trên classpath, log level và threshold, parameterised logging, logback.xml và log4j2.xml, MDC qua nhiều thread, cách log exception, cùng jdb, jcmd, jstack và jmap cho process đang chạy.

[Advanced Java] Xây dựng REST API với Spring Boot

REST API trên Spring Boot 4.1.1 và Java 21: DispatcherServlet route request ra sao, các annotation mapping, ResponseEntity và status code, jakarta.validation với @Valid, error body ProblemDetail theo RFC 9457, content negotiation và một lần chạy @WebMvcTest — mọi response đều lấy từ ứng dụng đang chạy.

[Advanced Java] Deadlock, Livelock trong Java và cách phòng tránh

Deadlock và livelock trong Java trên OpenJDK 21: bốn điều kiện Coffman, một deadlock tái hiện được mọi lần, cách chẩn đoán bằng thread dump thật từ jstack và ThreadMXBean.findDeadlockedThreads, phòng tránh bằng lock ordering toàn cục và ReentrantLock.tryLock kèm back-off, deadlock trong thread pool, livelock, starvation và cái giá của fair lock.

[Advanced Java] Class Collections trong Java: algorithm, wrapper và factory

java.util.Collections trên OpenJDK 21 sắp xếp theo đúng bản chất từng nhóm method: các algorithm ghi đè tại chỗ cùng mẹo insertion point của binarySearch, nCopies trả về một reference lặp n lần, ba wrapper unmodifiable, synchronized và checked vốn là view chứ không phải bản copy, checkedList bắt heap pollution ngay lúc insert, và các immutable factory đã thay thế phân nửa số method cũ.