Mở đầu — vì sao bài này quan trọng
Có một sự thật mà nhiều đội QA vẫn phớt lờ: test code cũng là code, và nó phải có chất lượng ngang — thậm chí cao hơn — production code. Nghe hơi ngược đời phải không? Nhưng hãy nghĩ kỹ. Production code có test bảo vệ. Còn test code thì ai bảo vệ? Không ai cả. Test code chính là lớp lưới an toàn (safety net) cuối cùng. Nếu cái lưới đó rách, mục, hay đan lỗ chỗ, thì cả hệ thống rơi tự do mà không ai hay biết.
Trong suốt các bài trước, bạn đã học cách viết test — từ Selenium, API automation, đến unit test và BDD. Bài này khác. Bài này không dạy bạn viết thêm test mới, mà dạy bạn nhìn lại test đã viết bằng con mắt phê phán: nhận diện code smell trong test, biết review test của đồng nghiệp thế nào cho có giá trị, và áp dụng những pattern giúp test bền vững qua thời gian.
Tôi từng chứng kiến một đội ở một fintech tại TP.HCM có 4.000 test case nhưng không ai dám sửa production code, vì mỗi lần sửa là hàng trăm test đỏ lè — mà chẳng ai biết test đỏ vì bug thật hay vì test viết ẩu. Đó là dấu hiệu điển hình của test code kém chất lượng: nó không còn là tài sản, nó trở thành nợ. Bài học hôm nay giúp bạn tránh đúng cái bẫy đó.
Khái niệm cốt lõi
Test code là first-class citizen
Nguyên tắc nền tảng: đừng bao giờ coi test code là "code hạng hai". Nó cần được đặt tên rõ ràng, tổ chức gọn gàng, không lặp lại, và dễ đọc. Khi một test fail lúc 2 giờ sáng trong pipeline, người trực (hoặc chính bạn) phải hiểu ngay lập tức test đang kiểm tra điều gì và tại sao nó fail — chỉ bằng cách đọc tên test và thông báo lỗi, không cần mở IDE debug.
Test code smells — bảng nhận diện
Dưới đây là những "mùi hôi" phổ biến nhất trong test code, mô tả và cách sửa:
| Smell | Mô tả | Cách fix |
|---|---|---|
| Assertion Roulette | Nhiều assert trong một test, không có message. Khi fail không biết assert nào chết | Thêm message mô tả cho từng assert, hoặc tách thành nhiều test nhỏ |
| Mystery Guest | Test phụ thuộc dữ liệu bên ngoài (file, DB thật) mà người đọc không thấy trong test | Đưa dữ liệu vào rõ ràng qua fixture/builder ngay trong test |
| Eager Test | Một test kiểm tra quá nhiều thứ cùng lúc | Áp dụng "one logical assertion per test" — mỗi test một hành vi |
| Test Code Duplication | Copy-paste setup lặp lại khắp nơi | Tách ra helper method, @BeforeEach, hoặc test data builder |
| Conditional Logic | Có if/else, for, try/catch trong test | Test phải tuyến tính, không nhánh. Logic điều kiện = test đang giấu bug |
| Magic Numbers/Strings | assertEquals(42, result) mà không ai biết 42 là gì | Đặt tên hằng số có ý nghĩa: EXPECTED_VIP_DISCOUNT |
| Sleepy Test | Thread.sleep(5000) để chờ | Dùng explicit wait / polling (đã học ở bài Wait Strategies) |
| Fragile Test | Test vỡ khi thay đổi nhỏ không liên quan | Không assert vào chi tiết implementation, chỉ assert hành vi quan sát được |
Nguyên tắc AAA và F.I.R.S.T
Hai khung tư duy giúp test luôn sạch:
AAA (Arrange – Act – Assert): Mỗi test nên chia rõ ba khối. Arrange chuẩn bị dữ liệu và trạng thái. Act thực hiện đúng một hành động cần kiểm tra. Assert kiểm chứng kết quả. Khi ba khối này lẫn lộn, test trở nên khó đọc. Chỉ cần thêm dòng trắng ngăn cách ba khối, khả năng đọc đã tăng vọt.
F.I.R.S.T mô tả đặc tính của test tốt:
- Fast — nhanh, để chạy được thường xuyên.
- Independent — độc lập, không test nào phụ thuộc thứ tự chạy của test khác.
- Repeatable — chạy đâu cũng ra kết quả như nhau (không phụ thuộc giờ hệ thống, timezone, network).
- Self-validating — tự cho ra pass/fail, không cần con người đọc log đối chiếu thủ công.
- Timely — viết đúng lúc, gần thời điểm viết production code.
Test naming — cái tên nói lên tất cả
Một test tên test1() hay loginTest() là vô dụng khi fail. Hãy dùng cấu trúc method_condition_expectedResult hoặc câu tiếng Anh mô tả hành vi:
// Xấu
@Test void loginTest() { ... }// Tốt
@Test void login_withExpiredPassword_shouldReturn401() { ... }
@Test void checkout_whenCartEmpty_showsWarningMessage() { ... }
Khi CI báo login_withExpiredPassword_shouldReturn401 FAILED, bạn đã biết chính xác chuyện gì hỏng mà chưa cần mở code.
DRY và DAMP — cân bằng tinh tế
Production code tôn thờ DRY (Don't Repeat Yourself). Nhưng test code lại cần cân bằng với DAMP (Descriptive And Meaningful Phrases). Nghĩa là: đừng trừu tượng hóa quá mức đến nỗi phải nhảy qua 5 lớp helper mới hiểu test làm gì. Một chút lặp lại có chủ đích trong test đôi khi tốt hơn một helper "thần bí". Quy tắc thực dụng: trừu tượng hóa phần setup phức tạp, nhưng giữ phần assert và ý định của test hiển thị rõ ngay trước mắt.
Tình huống thực tế
Ví dụ 1 — Assertion Roulette làm sập ca trực đêm tại một fintech
Một công ty ví điện tử ở Hà Nội (gọi là "PayViet") có test kiểm tra API tạo giao dịch. Một test duy nhất chứa 12 dòng assertEquals liên tiếp, không dòng message nào. Một đêm pipeline đỏ, kỹ sư trực nhận log chỉ vỏn vẹn: AssertionError: expected <200> but was <400>.
Anh ta mất 40 phút debug để biết assert thứ 7 (kiểm tra transaction.fee) mới là cái chết. Phía backend đã đổi công thức tính phí, nhưng vì log mù mờ, anh ta ban đầu tưởng API sập hoàn toàn và suýt gọi báo động cho cả team backend.
Sau sự cố, đội PayViet áp dụng quy tắc: mỗi assert phải có message, và mỗi test chỉ kiểm một nhóm hành vi logic. Test được tách thành createTransaction_returnsCorrectFee, createTransaction_returnsCorrectStatus... Lần sau khi phí lại đổi, log hiện ngay Expected fee 2200 but was 3000 — fee calculation changed. Thời gian chẩn đoán rớt từ 40 phút xuống dưới 2 phút.
Bài học: Message trong assert và tính đơn nhiệm của test không phải là "làm màu" — nó là thời gian sống của bạn lúc 2 giờ sáng.
Ví dụ 2 — Mystery Guest và cơn ác mộng dữ liệu chung
Tại một công ty thương mại điện tử ở Singapore, đội có ~600 UI test đọc dữ liệu người dùng từ một file test_users.csv dùng chung. Không test nào nói rõ nó dùng dòng nào. Một hôm, một bạn dev xóa một dòng "user không dùng nữa" trong CSV — và 80 test đỏ đồng loạt, ở những chỗ chẳng ai ngờ tới.
Đây chính là smell Mystery Guest: test phụ thuộc "vị khách bí ẩn" bên ngoài mà người đọc test không hề thấy. Không ai biết test nào ăn dòng nào, nên không ai dám đụng vào file.
Giải pháp họ áp dụng: chuyển sang Test Data Builder — mỗi test tự tạo dữ liệu nó cần ngay trong test:
User vipUser = aUser().withTier("VIP").withBalance(5_000_000).build();
Giờ dữ liệu hiển thị ngay trong test, không phụ thuộc file ngoài, và xóa file CSV chẳng ảnh hưởng ai. Số test flaky vì dữ liệu tụt gần về 0.
Bài học: Dữ liệu test nên hiện diện trong test. Khi người đọc phải đi tìm dữ liệu ở nơi khác, độ tin cậy và khả năng bảo trì sụp đổ.
Ví dụ 3 — Conditional logic giấu một bug thật
Một đội tại một startup logistics ở TP.HCM có đoạn test thế này:
@Test void calculateShippingFee() {
Order order = createOrder();
double fee = service.calcFee(order);
if (order.getWeight() > 10) {
assertTrue(fee > 50000);
} else {
assertEquals(30000, fee);
}
}
Nhìn qua thì "thông minh". Nhưng cái if chính là dấu hiệu test đang che giấu vấn đề: nếu logic phân nhánh trong test sai giống hệt logic trong production, test vẫn xanh trong khi bug vẫn tồn tại. Đúng như vậy — một lần công thức phí hàng nặng bị tính sai (60.000 thay vì 80.000), nhưng vì test chỉ assertTrue(fee > 50000), nó vẫn pass. Bug lọt lên production và khách hàng bị tính phí thiếu suốt 2 tuần.
Đội sửa bằng cách tách thành hai test riêng biệt, mỗi test có giá trị kỳ vọng cụ thể, cứng (assertEquals(80000, fee)). Không còn nhánh, không còn assertTrue mơ hồ.
Bài học: Test có if/else gần như luôn là red flag. Hãy tách thành các test tuyến tính với kỳ vọng chính xác.
Hướng dẫn từng bước
Đây là quy trình review một pull request chứa test code mà tôi khuyên bạn áp dụng:
Bước 1 — Đọc tên test trước, đọc thân test sau. Nếu chỉ đọc tên mà đã hiểu test kiểm tra gì, đó là dấu hiệu tốt. Nếu phải đọc hết thân mới đoán ra ý định, yêu cầu đổi tên.
Bước 2 — Kiểm tra cấu trúc AAA. Test có tách rõ Arrange / Act / Assert không? Có đúng một hành động Act không? Nếu có nhiều lời gọi "act" xen kẽ assert, đề nghị tách test.
Bước 3 — Soi từng assert. Có message không? Có magic number không? Assert vào hành vi quan sát được (kết quả trả về) hay vào chi tiết implementation (biến private, số lần gọi hàm nội bộ)? Assert vào implementation làm test giòn — cảnh báo tác giả.
Bước 4 — Săn code smell. Rà theo bảng smell ở trên: Thread.sleep? if/for/try trong test? Dữ liệu bí ẩn từ file/DB? Setup copy-paste? Đánh dấu từng chỗ.
Bước 5 — Kiểm tra tính độc lập. Test này có phụ thuộc test khác chạy trước không? Có ghi vào state toàn cục (biến static, DB không rollback) không? Thử tưởng tượng chạy riêng mình nó, hoặc chạy song song — có vỡ không?
Bước 6 — Đánh giá độ phủ ý nghĩa, không phải con số. Đừng hỏi "coverage bao nhiêu %". Hãy hỏi: "Nếu tôi cố tình cài một bug vào hàm này, có test nào bắt được không?" Đây chính là tư duy mutation testing ở mức trực giác.
Bước 7 — Để lại comment mang tính xây dựng. Review không phải để chê. Gợi ý cụ thể: "Nên tách test này thành 2, mỗi cái một hành vi" tốt hơn "test này dở".
Lỗi thường gặp & mẹo
- Chạy theo coverage %: Đạt 90% coverage không có nghĩa test tốt. Bạn có thể phủ 90% dòng code mà không có một assert nào ý nghĩa. Coverage đo dòng nào được chạy, không đo hành vi nào được kiểm chứng. Dùng coverage để tìm vùng chưa test, đừng dùng nó làm KPI thành tích.
- Over-mocking (lạm dụng mock): Test mock mọi thứ đến mức chỉ còn kiểm tra "mock trả về đúng cái tôi bảo nó trả về" — tức là test đang test chính cái mock, không test code thật. Mẹo: chỉ mock ở ranh giới hệ thống (network, DB, thời gian), giữ logic nghiệp vụ chạy thật.
- Test phụ thuộc thứ tự:
testAtạo user,testBgiả định user đó tồn tại. Chạy lẻ hoặc song song là vỡ. Mỗi test phải tự dựng và tự dọn trạng thái của mình.
- Copy-paste setup khắp nơi: Khi cấu trúc dữ liệu đổi, bạn phải sửa 50 chỗ. Tách setup vào builder / fixture, nhưng nhớ nguyên tắc DAMP: giữ phần ý định của test hiển thị.
- Comment thay vì đặt tên tốt: Nếu bạn phải viết
// test trường hợp user hết hạn thẻ, hãy đổi luôn tên test thànhlogin_withExpiredCard_.... Tên test tốt là comment tốt nhất.
- Mẹo vàng — "Đọc test như đọc tài liệu": Test suite tốt là tài liệu sống mô tả hệ thống làm gì. Nếu người mới vào đội đọc tên các test mà hiểu được nghiệp vụ, bạn đã đạt chất lượng test code mơ ước.
Bài tập thực hành
- Săn smell: Lấy 10 test bất kỳ trong dự án hiện tại (hoặc một repo open-source bạn thích). Với mỗi test, đối chiếu bảng smell và ghi ra tên smell nếu có. Đội nào không có dự án thì dùng repo mẫu bất kỳ trên GitHub có thư mục
test/.
- Refactor Assertion Roulette: Tìm một test có ≥3 assert không message. Viết lại: thêm message cho từng assert, hoặc tách thành nhiều test có tên mô tả rõ. So sánh thông báo lỗi trước và sau bằng cách cố tình làm một assert fail.
- Đổi tên: Chọn 5 test có tên mơ hồ (
test1,checkLogin...). Đổi sang cấu trúcmethod_condition_expectedResult. Đọc lại danh sách tên test — nó có kể được câu chuyện nghiệp vụ không?
- Viết Test Data Builder: Cho một đối tượng nghiệp vụ (ví dụ
OrderhoặcUser), viết một builder cho phép tạo dữ liệu test gọn gàng nhưanOrder().withWeight(15).build(). Thay dữ liệu bí ẩn trong 3 test bằng builder này.
- Mini code review: Ghép cặp với đồng nghiệp. Mỗi người review test PR của người kia theo đúng 7 bước ở phần Hướng dẫn, để lại ít nhất 3 comment mang tính xây dựng. Thảo luận điểm bất đồng.
Tóm tắt
Test code không phải công dân hạng hai — nó là tấm lưới an toàn của cả hệ thống, nên phải sạch, rõ ràng và dễ bảo trì ngang production code. Bạn đã học cách nhận diện các test code smell phổ biến (Assertion Roulette, Mystery Guest, Eager Test, Conditional Logic, Sleepy Test, Fragile Test), áp dụng khung tư duy AAA và F.I.R.S.T, đặt tên test có ý nghĩa, và cân bằng giữa DRY và DAMP. Ba tình huống thực tế cho thấy chất lượng test code ảnh hưởng trực tiếp đến thời gian chẩn đoán lỗi và cả việc bug có lọt lên production hay không.
Quan trọng nhất, bạn có một quy trình review test PR gồm 7 bước để áp dụng ngay ngày mai, và một tư duy mới: đừng đo test bằng coverage %, hãy đo bằng câu hỏi "nếu tôi cài một bug vào đây, test có bắt được không?". Test code chất lượng cao chính là khoản đầu tư giúp đội bạn dám thay đổi code mà không sợ hãi — và đó là mục tiêu cuối cùng của mọi automation.