Hãy coi một cái test như tờ giấy nhắn bạn để lại cho chính mình — và nó luôn được mở ra vào đúng lúc tệ nhất: 6 giờ chiều thứ Sáu, CI đỏ, mọi người đang chờ. Một tờ nhắn tốt viết "cần 1.234,50 nhưng nhận 1.234,99"; một tờ nhắn tồi viết "cần đúng nhưng nhận sai" và bắt bạn mở lại mã, đọc điều kiện, chạy tay để biết cái mình lẽ ra đã biết. Viết test thì ai cũng viết được — viết test đọc được lúc bực nhất mới là chuyện khác, và bài này về chuyện đó.

Vòng đời, in ra thật

  @BeforeAll   — một lần cho cả lớp
    @BeforeEach  trước testHai
      testHai chạy   | tĩnh=1 thực thể=1 | đối tượng=204715855
    @AfterEach   sau  testHai
    @BeforeEach  trước testMot
      testMot chạy   | tĩnh=2 thực thể=1 | đối tượng=173099767
    @AfterEach   sau  testMot
  @AfterAll    — một lần cho cả lớp

Hai điều lộ ra ngay, và cả hai đều là quyết định thiết kế chứ không phải tình cờ.

Mỗi test nhận một đối tượng mới. Hai mã băm khác nhau, và biến thực thể demThucThe luôn bằng 1 trong khi biến tĩnh demTinh tăng dần. JUnit tạo lại thực thể lớp test cho từng phương thức.

Nghĩa là bạn không cần dọn dẹp trạng thái giữa các test — nó tự sạch. Và nó cũng là lý do @BeforeAll phải static: lúc nó chạy thì chưa có thực thể nào.

Muốn đổi thì có @TestInstance(Lifecycle.PER_CLASS), khi đó @BeforeAll không cần static nữa. Tôi hiếm khi dùng, vì nó mở cửa cho trạng thái rò rỉ giữa các test — đúng thứ mặc định đang chặn.

Thứ tự chạy không phải thứ tự khai báo. testHai chạy trước testMot dù viết sau.

Đây là cố ý. JUnit dùng một thứ tự ổn định nhưng không hiển nhiên, để bạn không vô tình viết test phụ thuộc lẫn nhau. Test thứ hai không được quyền dựa vào việc test thứ nhất đã chạy.

Nếu bạn thấy mình cần @TestMethodOrder, hãy dừng lại hỏi vì sao — gần như luôn là dấu hiệu các test đang chia sẻ trạng thái, và đó là lỗi thiết kế chứ không phải thiếu tính năng.

Thông báo hỏng: khác biệt giữa hữu ích và vô dụng

Đây là phần tôi thấy đáng đầu tư nhất, vì bạn đọc thông báo lỗi vào lúc bực bội nhất.

Bốn cách viết cùng một phép kiểm, và thông báo khi hỏng:

  assertEquals(mong, thuc)              -> expected: <1.234,50> but was: <1,234,99>
  assertTrue(a.equals(b))               -> expected: <true> but was: <false>
  assertTrue(cond, () -> "mong ... ")   -> mong '2,00' nhưng nhận '1,00' ==> expected: <true> but was: <false>
  assertEquals(List.of(...), ...)       -> expected: <[a, b, c]> but was: <[a, x, c]>

Dòng thứ hai là thứ cần tránh bằng mọi giá. expected: <true> but was: <false> không nói gì cả — bạn phải mở mã, đọc điều kiện, rồi tự chạy lại để biết giá trị thật là gì.

Quy tắc: dùng assertEquals bất cứ khi nào có thể, vì nó tự in cả hai giá trị. Chỉ dùng assertTrue khi thật sự kiểm một điều kiện boolean, và khi đó luôn kèm thông điệp.

Chú ý thông điệp viết dạng lambda () -> "...": nó chỉ được tính khi test hỏng. Với thông điệp ghép chuỗi tốn kém, đó là khác biệt thật trong bộ test lớn.

assertAll: báo hết thay vì báo cái đầu tiên

assertAll("định dạng tiền",
    () -> assertEquals("0,01", Tien.dinhDang(1)),
    () -> assertEquals("1,00", Tien.dinhDang(101)),
    () -> assertEquals("10,00", Tien.dinhDang(1000)));
  org.opentest4j.MultipleFailuresError:
  định dạng tiền (1 failure)
      AssertionFailedError: expected: <1,00> but was: <1,01>

Không có assertAll, khẳng định đầu tiên hỏng là dừng — bạn sửa, chạy lại, gặp khẳng định thứ hai hỏng, sửa tiếp. Ba vòng cho ba lỗi.

Với assertAll, bạn thấy cả ba cùng lúc. Đây là lựa chọn đúng khi kiểm nhiều thuộc tính của cùng một kết quả.

Ngược lại, đừng dùng nó khi các khẳng định phụ thuộc nhau — kiểm null rồi mới truy cập trường chẳng hạn. Lúc đó khẳng định thứ hai sẽ ném NPE thay vì báo lỗi rõ ràng.

Ba dòng thay cho mười một test

@ParameterizedTest(name = "{0} xu -> \"{1}\"")
@CsvSource({ "1, '0,01'", "100, '1,00'", "123456, '1,234,56'", "0, '0,00'" })
void doiXuThanhChuoi(long xu, String mong){ assertEquals(mong, Tien.dinhDang(xu)); }

@ParameterizedTest(name = "số âm {0} phải bị từ chối")
@ValueSource(longs = { -1, -100, -999999 })
void soAmBiTuChoi(long xu){ assertThrows(IllegalArgumentException.class, () -> Tien.dinhDang(xu)); }
  Tests run: 11, Failures: 0
    doiXuThanhChuoi(long, String)[1] .. [4]
    soAmBiTuChoi(long)[1] .. [3]
    bien(long, String)[1] .. [2]

Ba phương thức, mười một lần chạy. Và mỗi lần là một test độc lập — hỏng một trường hợp không che mất các trường hợp còn lại, khác hẳn khi bạn viết một vòng for bên trong một test.

Ba nguồn dữ liệu hay dùng:

@ValueSource — một tham số, danh sách giá trị đơn giản.

@CsvSource — nhiều tham số. Chú ý cách viết chuỗi có dấu phẩy: "123456, '1,234,56'" — dấu nháy đơn để dấu phẩy bên trong không bị hiểu là dấu phân cách cột.

@MethodSource — khi dữ liệu phải tính toán hoặc là đối tượng phức tạp:

static Stream<Arguments> truongHopBien(){
    return Stream.of(Arguments.of(0L, "0,00"), Arguments.of(Long.MAX_VALUE/100*100, "..."));
}

Còn có @CsvFileSource đọc từ tệp, và @EnumSource chạy qua mọi giá trị của một enum — cái sau rất hợp để đảm bảo không quên xử lý một trạng thái nào.

Tham số name quyết định tên hiển thị. {0}, {1} là các đối số. Đặt tên tử tế ở đây rất đáng, vì khi test đỏ trên CI, cái bạn thấy đầu tiên là dòng tên đó.

@Nested: nhóm theo hoàn cảnh

@DisplayName("Định dạng tiền tệ")
class ThamSoTest {
    @Nested @DisplayName("khi số tiền lớn")
    class SoLon {
        @Test @DisplayName("có dấu phân cách hàng nghìn") void a(){ ... }
        @Test @DisplayName("không mất chữ số nào")        void b(){ ... }
    }
}

Kết quả đọc lên thành câu: Định dạng tiền tệ → khi số tiền lớn → có dấu phân cách hàng nghìn.

Lợi ích thật của @Nested không phải trình bày mà là @BeforeEach riêng cho từng nhóm. Mỗi lớp lồng dựng hoàn cảnh của nó, thay vì một @BeforeEach khổng lồ ở lớp ngoài chuẩn bị cho mọi tình huống.

Một cái bẫy khi đếm test mà tôi gặp đúng khi làm bài này:

  Tests run: 11 -- in ThamSoTest$SoLon
  Tests run:  0 -- in ThamSoTest

Surefire ghi báo cáo riêng cho lớp lồng, và ở đây nó gán toàn bộ 11 test cho lớp lồng còn lớp cha hiện 0. Nên đừng cộng số test từ các tệp báo cáo — hãy lấy con số tổng ở dòng cuối của mvn test.

Đặt tên cho người sau đọc

Ba quy ước tôi dùng:

Tên phương thức mô tả hành vi, không mô tả phương thức được gọi. soAmBiTuChoi tốt hơn testDinhDang2. Khi test đỏ, cái tên phải nói cái gì đã sai, không phải hàm nào được gọi.

@DisplayName cho tiếng Việt và dấu câu. Tên phương thức bị giới hạn bởi cú pháp Java; @DisplayName thì không.

Cấu trúc ba đoạn trong thân test — chuẩn bị, thực thi, kiểm tra. Không cần comment, chỉ cần một dòng trống giữa các đoạn. Test nào không tách được thành ba đoạn thường là đang kiểm quá nhiều thứ.

Và một điều tôi tin chắc sau nhiều lần bị chính mình làm phiền: một test kiểm một thứ. Test hỏng phải trỏ tới đúng một nguyên nhân. Nếu một test có thể hỏng vì năm lý do khác nhau, nó không giúp bạn gỡ lỗi — nó chỉ báo động.

Vài chú thích khác đáng biết

@Disabled("lý do") — luôn ghi lý do. Test bị tắt không có lý do sẽ nằm đó vĩnh viễn.

@Timeout(5) — chặn test treo làm đứng cả bộ.

@Tag("cham") cộng mvn test -Dgroups=cham — tách test nhanh và test chậm.

assertThrows trả về chính ngoại lệ, nên kiểm được cả thông điệp:

var ex = assertThrows(IllegalArgumentException.class, () -> Tien.dinhDang(-5));
assertTrue(ex.getMessage().contains("âm"));

Kiểm cả thông điệp là thói quen tốt: nó đảm bảo bạn bắt đúng lỗi mình nghĩ, chứ không phải một ngoại lệ cùng kiểu đến từ chỗ khác.

Muốn tự sửa một chỗ trong ba mươi giây: tìm trong bộ test chuỗi assertTrue(, và với mỗi chỗ không có tham số thông điệp, thêm một cái — hoặc đổi hẳn sang assertEquals. Ba mươi giây cho mỗi dòng, và lần tới khi test đó đỏ trên CI lúc 6 giờ chiều thứ Sáu, bạn sẽ biết ngay giá trị thật là gì thay vì đọc expected: <true> but was: <false>.

Mẫu số chung

Cái tờ giấy nhắn đọc lúc 6 giờ chiều thứ Sáu không phải chuyện riêng của Java — mọi hệ sinh thái test đều xoay quanh đúng ba ý, và chúng giải quyết cái bẫy assertTrue theo những cách đáng học. Go không có thư viện khẳng định trong chuẩn, nhưng quy ước t.Errorf("got %v, want %v", got, want) chính là buộc bạn in cả hai giá trị — đúng thứ assertEquals làm sẵn; và "test theo bảng" (một slice các ca chạy trong t.Run) là bản sao của @ParameterizedTest, mỗi ca một test con độc lập. Python pytest đi xa nhất: nó viết lại câu assert a == b để tự in ra cả hai vế khi hỏng — nghĩa là cái bẫy "expected true but was false" không tồn tại ở pytest, bạn cứ assert bình thường mà vẫn có thông báo đầy đủ; cộng @pytest.mark.parametrize cho ca theo tham số. Rust có assert_eq! in cả hai bên khi hỏng, khác hẳn assert! trần. JavaScript Jest thì expect(x).toEqual(y) in ra bản diff.

Điểm chung, và là thứ đáng mang theo, gồm ba luật mà framework nào cũng thưởng cho ai theo. Một — một test hỏng phải nói cái gì và vì sao, không chỉ rằng nó hỏng: luôn để công cụ in ra giá trị thật so với giá trị mong đợi, nên assertEquals/assert_eq!/got-want thắng assertTrue trần ở mọi ngôn ngữ (và pytest xoá luôn lựa chọn sai). Hai — mỗi test phải độc lập: không dựa vào thứ tự, không chia sẻ trạng thái — đó là lý do JUnit cố tình xáo thứ tự và tạo lại đối tượng mỗi lần, và là lý do Go/pytest tách từng ca thành test con riêng. Ba — gộp N bản sao thành một test theo tham số giữ cho mỗi ca vẫn độc lập mà không phải chép mã. Và bao trùm tất cả là câu hỏi giản dị: người mở tờ nhắn này lúc bực nhất có hiểu ngay chuyện gì đã sai không? Test nào không trả lời được câu đó thì chưa làm xong việc của nó.

Ngày mai: test đôi — mock, stub, spy — dùng đúng chỗ và những chỗ chúng làm test tệ đi.