Viết test thì ai cũng viết được. Viết test mà sáu tháng sau người khác đọc vẫn hiểu, và khi nó đỏ thì biết ngay vì sao — đó là chuyện khác.
Bài này về JUnit 5 với trọng tâm ở chỗ thứ hai.
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.
Thử ba mươi giây
Tìm trong bộ test của bạn chuỗi assertTrue(.
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>.
Ngày mai: test đôi — mock, stub, spy — dùng đúng chỗ và những chỗ chúng làm test tệ đi.