Một test bất đồng bộ viết sai không đỏ — nó xanh. Đó là điều làm nó nguy hiểm hơn hẳn một test hỏng, vì một test hỏng thì bạn sửa, còn một test xanh giả thì bạn tin.
Tôi viết bốn test cùng gọi một endpoint trả về 200 và cùng khẳng định statusCode == 999. Tất cả đều sai. Câu hỏi là cái nào đỏ.
Bốn cách viết, cùng một khẳng định sai
// 1. khang dinh trong callback, khong doi gi
@Test void a_khongDoi(){
wc.get(cong, "localhost", "/nhanh").send()
.onSuccess(r -> assertEquals(999, r.statusCode()));
}
// 2. khang dinh trong callback, co Thread.sleep
@Test void b_coDoi() throws Exception {
wc.get(cong, "localhost", "/nhanh").send()
.onSuccess(r -> assertEquals(999, r.statusCode()));
Thread.sleep(300);
}
// 3. VertxTestContext nhung KHONG dung ctx.verify
@Test void c_quenVerify(VertxTestContext ctx){
wc.get(cong, "localhost", "/nhanh").send()
.onComplete(ctx.succeeding(r -> {
assertEquals(999, r.statusCode());
ctx.completeNow();
}));
}
// 4. dung ctx.verify
@Test void d_dungCach(VertxTestContext ctx){
wc.get(cong, "localhost", "/nhanh").send()
.onComplete(ctx.succeeding(r -> ctx.verify(() -> {
assertEquals(999, r.statusCode());
ctx.completeNow();
})));
}
Kết quả chạy thật:
├─ a_khangDinhTrongCallback_khongDoi() ✔
├─ b_khangDinhTrongCallback_coDoiNhungNuot() ✔
├─ c_dungCompleteNow_nhungQuenVerify() ✘ expected: <999> but was: <200>
└─ d_dungCach() ✘ expected: <999> but was: <200>
[ 2 tests successful ]
[ 2 tests failed ]
Hai test khẳng định 200 == 999 và vẫn xanh.
Cách 1 xanh vì phương thức test kết thúc ngay sau khi send() trả về — callback chưa chạy, khẳng định chưa bao giờ được thực hiện. JUnit thấy phương thức chạy xong không ném gì, và kết luận là đạt.
Cách 2 xanh vì lý do khác và tinh vi hơn: callback có chạy, khẳng định có thất bại, nhưng AssertionError được ném ra trên luồng event loop, không phải luồng chạy test. JUnit chỉ nhìn luồng của nó. Ngoại lệ kia rơi vào bộ xử lý mặc định của Vert.x, xuất hiện trong log dưới dạng một dòng lỗi mà không ai đọc, và test vẫn xanh.
Đây là lý do Thread.sleep trong test bất đồng bộ không phải là giải pháp tồi, mà là giải pháp sai: nó chữa triệu chứng "callback chưa kịp chạy" nhưng không chữa được chuyện "khẳng định thất bại ở nơi JUnit không nhìn".
Một dự đoán của tôi sai
Tôi viết cách 3 để làm ví dụ về xanh giả thứ ba — dùng VertxTestContext nhưng quên ctx.verify. Tôi tưởng AssertionError sẽ lại bị nuốt.
Nó đỏ. ctx.succeeding(...) không chỉ kiểm tra rằng Future thành công; nó còn bắt mọi Throwable ném ra từ callback bạn đưa vào và báo hỏng cho test context. Nghĩa là chỉ cần bọc callback bằng succeeding/failing là bạn đã được che phần lớn.
Vậy ctx.verify còn để làm gì? Cho những chỗ không đi qua succeeding — callback của một ReadStream, một handler của event bus, một bộ định giờ:
vertx.eventBus().consumer("kenh", m -> ctx.verify(() -> {
assertEquals("mong doi", m.body());
ctx.completeNow();
}));
Ở đó không có gì bọc sẵn, và không có ctx.verify thì bạn quay lại đúng trường hợp 2.
Thói quen an toàn là luôn bọc mọi khẳng định nằm trong callback bằng ctx.verify, kể cả khi đã có succeeding. Nó không tốn gì, và bạn khỏi phải nhớ chỗ nào được che sẵn chỗ nào không.
Bộ test sleep chậm hơn 11,7 lần
Bốn test giống hệt nhau về nội dung, viết theo hai kiểu. Chạy ba lần mỗi kiểu:
| Lần 1 | Lần 2 | Lần 3 | |
|---|---|---|---|
Kiểu Thread.sleep |
4 762 ms | 4 725 ms | 4 681 ms |
Kiểu vertx-junit5 |
411 ms | 391 ms | 396 ms |
Gấp 11,7 lần. Bốn test mà chênh nhau bốn giây; một bộ test thật có vài trăm test thì đó là chênh lệch giữa "chạy được sau mỗi lần lưu file" và "chạy trên CI rồi đi pha cà phê".
Lý do rất đơn giản: Thread.sleep(500) luôn tốn đúng 500 ms, kể cả khi request xong sau 3 ms. VertxTestContext kết thúc ngay khi completeNow() được gọi. Bạn trả đúng thời gian thật cần thay vì trả theo con số mình đoán.
Và con số đoán đó là nguồn của loại test chập chờn tệ nhất: đủ dài trên máy bạn, không đủ dài trên CI đang chạy sáu việc cùng lúc. Cách chữa quen thuộc là tăng sleep lên, làm bộ test chậm thêm, cho tới ngày không ai chạy nó nữa.
Bộ khung tối thiểu
@ExtendWith(VertxExtension.class)
class DichVuTest {
@BeforeAll static void dung(Vertx vertx, VertxTestContext ctx){
vertx.deployVerticle(new DichVuThu())
.onComplete(ctx.succeeding(id -> ctx.completeNow()));
}
@Test void duongNhanh(Vertx vertx, VertxTestContext ctx){
WebClient.create(vertx).get(cong, "localhost", "/nhanh").send()
.onComplete(ctx.succeeding(r -> ctx.verify(() -> {
assertEquals(200, r.statusCode());
ctx.completeNow();
})));
}
}
VertxExtension tiêm sẵn Vertx và VertxTestContext vào tham số, tự dọn Vertx sau mỗi test, và tự cho test hỏng nếu completeNow() không được gọi trong thời hạn. Ba việc đó thay cho toàn bộ phần @BeforeAll / @AfterAll / sleep viết tay.
Vài điều đáng nhớ khi dùng:
completeNow()phải được gọi đúng một lần trên mọi nhánh. Quên ở nhánh lỗi là test treo tới hết thời hạn rồi mới đỏ — vẫn đúng kết luận, nhưng chậm và khó đọc.- Nhiều bước thì dùng
Checkpoint:ctx.checkpoint(3)cho một test cần ba sự kiện xảy ra, thay vì đếm bằng tay. - Cho cổng là 0 khi dựng máy chủ trong test rồi đọc lại
server.actualPort(). Cổng cố định làm test hỏng khi chạy song song, và phần 1 của sê-ri này đã kể chuyện tôi mất nửa buổi vì một container chiếm mất cổng.
Điều test không bắt được
Một cảnh báo để đóng phần này: bộ test chạy Vert.x không cụm, và phần 43 đã đo được rằng getLock cùng getCounter chạy tốt khi không cụm rồi hỏng khi có cụm. Nghĩa là một bộ test xanh hoàn toàn vẫn có thể che một lỗi chỉ xuất hiện trên môi trường thật.
Cùng logic đó áp cho những thứ khác sê-ri này đã đo: test không bắt được event loop bị chặn, không bắt được pool cạn, không bắt được thiếu áp lực ngược. Chúng chỉ lộ ra dưới tải hoặc dưới cụm — và đó là lý do mọi bài trong sê-ri này đều kết bằng một phép đo chạy thật chứ không phải một test.
Thử ba mươi giây
Tìm test xanh giả trong dự án của bạn — sửa một khẳng định thành thứ chắc chắn sai rồi chạy lại:
assertEquals(999, r.statusCode()); // doi tam thoi
Test vẫn xanh nghĩa là nó chưa bao giờ kiểm tra gì. Làm thử với vài test bất đồng bộ mà bạn tin tưởng nhất; con số bạn tìm được thường nhiều hơn mong đợi.
Phần sau đóng gói ứng dụng Vert.x vào Docker và đo thời gian khởi động cùng dung lượng ảnh.