Mở đầu — vì sao bài này quan trọng
Ở bài trước chúng ta đã học các kỹ thuật thiết kế test case theo hướng Black Box — tức là chỉ nhìn vào đầu vào và đầu ra, coi phần mềm như một chiếc hộp đen mà ta không biết bên trong nó viết gì. Bài này chúng ta mở nắp chiếc hộp đó ra và nhìn thẳng vào mã nguồn. Đó chính là White Box Testing (kiểm thử hộp trắng, còn gọi là Structural Testing hay Glass Box Testing).
Vì sao một QA Leader hay một QA có định hướng lên SDET phải nắm chắc White Box? Bởi vì Black Box có một điểm mù chí mạng: bạn có thể viết 50 test case đầu-cuối trông rất đẹp mà vẫn có những nhánh code chưa từng được chạy qua lần nào. Những nhánh chưa chạy đó chính là nơi bug ẩn nấp. Khi một hệ thống ngân hàng ở Việt Nam gặp sự cố "tính lãi sai trong trường hợp khách VIP có số dư âm", gần như chắc chắn đó là một nhánh else if mà không test case Black Box nào chạm tới.
White Box cho bạn một thứ mà Black Box không bao giờ có: con số đo lường khách quan về mức độ code đã được test — gọi là coverage (độ bao phủ). Khi bạn báo cáo với sếp "chúng ta đã test 80% code" thay vì "chúng ta test kỹ rồi", bạn đang nói ngôn ngữ của kỹ thuật, không phải cảm tính. Đó là lý do bài này quan trọng: nó biến việc "test cho yên tâm" thành "test có bằng chứng".
Khái niệm cốt lõi
White Box Testing dựa trên việc phân tích cấu trúc bên trong của mã nguồn: các câu lệnh, các điều kiện rẽ nhánh, các vòng lặp, các đường đi (path) qua chương trình. Từ cấu trúc đó, ta thiết kế test case sao cho "quét" qua càng nhiều phần của code càng tốt. Mức độ quét được đo bằng các loại coverage criteria (tiêu chí bao phủ). Ta đi từ yếu đến mạnh.
Statement Coverage — Bao phủ câu lệnh
Đây là mức yếu nhất và dễ hiểu nhất: mỗi dòng lệnh (statement) phải được thực thi ít nhất một lần. Xét ví dụ trong ghi chú gốc:
def discount(price, vip):
if vip:
return price * 0.8
return price
Hàm này có 4 dòng lệnh có ý nghĩa. Nếu ta chỉ chạy một test discount(100, True), ta đã chạy qua dòng if vip: và dòng return price * 0.8, nhưng chưa bao giờ chạy dòng return price. Statement coverage lúc này chỉ đạt 3/4 = 75%. Để đạt 100% statement coverage ta cần thêm test discount(100, False) để chạm vào dòng return price.
Công thức: Statement Coverage = (số câu lệnh đã thực thi / tổng số câu lệnh) × 100%.
Điểm yếu của statement coverage: nó không quan tâm điều kiện đúng/sai. Với một câu if không có else, đôi khi một test đã đủ để chạy hết mọi dòng, nhưng nhánh "điều kiện sai" chưa hề được kiểm tra.
Branch (Decision) Coverage — Bao phủ nhánh
Mạnh hơn statement coverage. Yêu cầu: mỗi nhánh rẽ (mỗi kết quả True/False của mỗi điều kiện quyết định) phải được thực thi ít nhất một lần. Nói cách khác, mỗi if phải được test cả khi đúng lẫn khi sai, kể cả khi không viết else.
Quay lại ví dụ trên: để đạt 100% branch coverage, ta bắt buộc phải có cả vip=True (nhánh True) và vip=False (nhánh False). Với hàm đơn giản này, 100% branch coverage cũng kéo theo 100% statement coverage. Nhưng lưu ý quan hệ một chiều: đạt 100% branch coverage luôn kéo theo 100% statement coverage, nhưng ngược lại thì không.
Condition & MC/DC Coverage — Bao phủ điều kiện
Khi một if chứa nhiều điều kiện con nối bằng AND/OR, branch coverage vẫn chưa đủ. Ví dụ:
if age >= 18 and has_income:
approve_loan()
Branch coverage chỉ cần điều kiện tổng ra True một lần và False một lần. Nhưng ta có thể đạt điều đó mà chưa bao giờ kiểm tra riêng ảnh hưởng của has_income. Condition Coverage yêu cầu mỗi điều kiện con (age >= 18, has_income) phải nhận cả giá trị True lẫn False.
Mức cao nhất, dùng trong các ngành khắt khe như hàng không, y tế, ô tô (chuẩn DO-178C, ISO 26262), là MC/DC — Modified Condition/Decision Coverage: mỗi điều kiện con phải được chứng minh là độc lập tác động tới kết quả cuối. Nghĩa là bạn phải tìm được cặp test mà chỉ thay đổi một điều kiện con thì kết quả decision thay đổi theo. Đây là chuẩn "vàng" nhưng tốn kém, thường chỉ áp dụng cho phần mềm an toàn tính mạng.
Path Coverage — Bao phủ đường đi
Mạnh nhất về lý thuyết: mọi đường đi khả thi từ đầu đến cuối chương trình phải được chạy qua. Với một hàm có 3 câu if độc lập, số đường đi có thể lên tới 2³ = 8. Có vòng lặp thì số path bùng nổ vô hạn về lý thuyết. Vì vậy path coverage 100% gần như không đạt được trong thực tế với code phức tạp — ta dùng nó như một lý tưởng để tham chiếu, và dùng Cyclomatic Complexity (độ phức tạp chu trình của McCabe) để ước lượng số path độc lập tối thiểu cần test.
Thứ tự sức mạnh cần nhớ
Statement < Branch/Decision < Condition < MC/DC < Path. Trong thực tế công nghiệp, mục tiêu phổ biến và hợp lý là branch coverage 70–85% cho code nghiệp vụ quan trọng, không phải 100% mọi thứ.
Tình huống thực tế
Ví dụ 1 — Fintech Việt Nam và cái bẫy statement coverage 100%
Một công ty ví điện tử tại TP.HCM (giả định tên PayZo) có quy định nội bộ: mọi Pull Request phải đạt tối thiểu 80% code coverage mới được merge. Team rất tự hào vì module tính phí giao dịch đạt 100% statement coverage. Thế nhưng sau khi lên production, có sự cố: giao dịch chuyển tiền đúng 500.000 đồng (đúng ngưỡng miễn phí) bị tính phí sai.
Điều tra ra mới thấy đoạn code:
def fee(amount):
if amount > 500000:
return amount * 0.001
return 0
Test cũ chỉ chạy fee(1000000) và fee(0). Cả hai test này đã chạy đủ mọi câu lệnh — nên statement coverage 100%. Nhưng chúng chưa bao giờ test đúng biên amount = 500000. Bug thực ra nằm ở chỗ lập trình viên viết > trong khi yêu cầu là >=. Bài học: 100% statement coverage nghe rất oai nhưng không phát hiện được lỗi biên và lỗi logic điều kiện. PayZo sau đó đổi tiêu chí bắt buộc từ statement sang branch coverage, đồng thời kết hợp kỹ thuật Boundary Value Analysis (từ bài Black Box) để bắt lỗi biên. Coverage con số cao không đồng nghĩa với chất lượng cao — nó chỉ nói code đã được chạy, không nói code đã được kiểm chứng đúng.
Ví dụ 2 — E-commerce và Cyclomatic Complexity cảnh báo sớm
Một sàn thương mại điện tử ở Hà Nội (giả định ShopViet) có một hàm calculate_shipping() tính phí ship dựa trên: khu vực (nội thành/ngoại thành/tỉnh), trọng lượng, có phải khách Prime không, có mã giảm giá không, và có phải hàng cồng kềnh không. Hàm này dài 120 dòng với hàng chục if-elif lồng nhau.
SDET của team chạy công cụ đo Cyclomatic Complexity và ra con số 24. Theo thang McCabe, complexity trên 10 đã là "khó bảo trì, dễ có bug", trên 20 là "vùng nguy hiểm". Con số 24 nghĩa là cần tối thiểu 24 test case độc lập để bao phủ hết các nhánh logic. Team nhận ra viết đủ 24 test cho một hàm là dấu hiệu hàm này làm quá nhiều việc. Họ refactor tách thành 4 hàm nhỏ (mỗi hàm complexity 5–6), mỗi hàm dễ test riêng. Kết quả: branch coverage tăng từ 61% lên 92% mà số dòng test không tăng thêm nhiều, và ba bug tính phí ship cho "hàng cồng kềnh + khách Prime + ngoại thành" bị lộ ngay khi viết test cho nhánh đó. Bài học: White Box không chỉ để test — Cyclomatic Complexity còn là một chỉ báo thiết kế. Code khó test thường là code thiết kế tệ.
Ví dụ 3 — Ngân hàng và MC/DC cho logic phê duyệt tín dụng
Một ngân hàng số ở Đông Nam Á triển khai hệ thống chấm điểm tín dụng tự động. Logic phê duyệt: if (thu_nhập >= 15tr AND điểm_CIC_tốt) OR khách_hàng_VIP: duyệt. Team QA ban đầu chỉ làm branch coverage: một ca duyệt, một ca từ chối — coverage xanh. Nhưng đội kiểm toán rủi ro (risk audit) yêu cầu chuẩn cao hơn vì đây là quyết định tài chính.
Họ áp dụng MC/DC. Khi thiết kế các cặp test để chứng minh mỗi điều kiện tác động độc lập, QA phát hiện: với logic đã viết, một khách VIP nhưng điểm CIC rất xấu (đang nợ xấu nhóm 5) vẫn được auto-duyệt vì nhánh OR khách_hàng_VIP ghi đè mọi thứ. Đây là lỗi nghiệp vụ nghiêm trọng mà branch coverage không lộ ra, chỉ khi buộc phải cô lập từng điều kiện thì mới thấy. Bài học: với logic điều kiện phức hợp có rủi ro cao (tài chính, tuân thủ, an toàn), branch coverage là chưa đủ — cần leo lên condition/MC/DC coverage. Mức độ coverage phải tương xứng với mức độ rủi ro của module.
Hướng dẫn từng bước
Đây là quy trình áp dụng White Box vào một hàm/module cụ thể:
- Lấy mã nguồn và xác định phạm vi. Chọn hàm hoặc lớp cần test. Đọc để hiểu nó làm gì và các điều kiện rẽ nhánh có trong đó.
- Vẽ Control Flow Graph (CFG) nếu logic phức tạp. Mỗi câu lệnh/khối là một node, mỗi rẽ nhánh là một cạnh. CFG giúp bạn "nhìn thấy" các đường đi mà mắt thường dễ bỏ sót.
- Tính Cyclomatic Complexity. Công thức nhanh:
V(G) = số điểm rẽ nhánh + 1(đếm sốif,while,for,and,or,case). Con số này là số test case độc lập tối thiểu để đạt branch coverage.
- Chọn coverage criteria phù hợp rủi ro. Code nghiệp vụ thường: branch. Code tài chính/an toàn: condition hoặc MC/DC. Đừng ép 100% path cho mọi thứ.
- Thiết kế test case cho từng nhánh. Với mỗi điều kiện, tạo dữ liệu đầu vào để nó ra True, và đầu vào để ra False. Kết hợp với Boundary Value Analysis để bắt lỗi biên.
- Chạy công cụ đo coverage. Java dùng JaCoCo; JavaScript/TypeScript dùng Istanbul/nyc hoặc built-in của Jest/Vitest; Python dùng
coverage.py(pytest --cov); C#/.NET dùng Coverlet. Xem báo cáo highlight dòng đỏ (chưa chạy).
- Bổ sung test cho vùng đỏ. Nhìn báo cáo, thấy nhánh nào chưa được chạy thì viết thêm test nhắm đúng nhánh đó.
- Đưa coverage gate vào CI/CD. Đặt ngưỡng tối thiểu (ví dụ 80% branch) trong pipeline. PR không đạt thì fail. Nhưng đặt ngưỡng thông minh, không cực đoan.
Lỗi thường gặp & mẹo
Lỗi 1 — Thờ phượng con số coverage. Sai lầm phổ biến nhất: coi 100% coverage là mục tiêu tối thượng. Coverage cao chỉ chứng minh code được chạy qua, không chứng minh assertion đúng. Một test không có assert nào vẫn làm coverage tăng nhưng chẳng kiểm chứng gì. Mẹo: luôn hỏi "test này có kiểm chứng đúng hành vi không?" chứ không chỉ "nó có tăng coverage không?".
Lỗi 2 — Nhầm statement với branch. Nhiều team báo cáo "coverage 90%" mà không nói rõ loại nào. Statement 90% và branch 90% là hai thế giới khác nhau. Mẹo: luôn cấu hình công cụ hiển thị branch coverage, đó mới là con số đáng tin.
Lỗi 3 — Bỏ qua các nhánh xử lý lỗi/exception. Các khối catch, else, xử lý null thường ít được test vì "khó tạo tình huống". Đó chính là chỗ hay sập production. Mẹo: chủ động dùng mock để ép hệ thống ném lỗi, test cho bằng được nhánh xử lý ngoại lệ.
Lỗi 4 — Viết test bám chặt vào cấu trúc code (over-fitting). White Box test dễ bị "vỡ" khi refactor code dù hành vi không đổi. Mẹo: cân bằng — dùng White Box để đảm bảo coverage nhưng viết assertion dựa trên hành vi/kết quả, không dựa trên chi tiết cài đặt nội bộ.
Mẹo vàng: Kết hợp White Box với Black Box. Black Box đảm bảo bạn test đúng yêu cầu, White Box đảm bảo bạn không bỏ sót nhánh code. Hai kỹ thuật bổ trợ nhau, không thay thế nhau.
Bài tập thực hành
Bài 1 — Tính coverage. Cho hàm sau:
def grade(score, is_bonus):
if score >= 90:
result = "A"
elif score >= 70:
result = "B"
else:
result = "C"
if is_bonus:
result = result + "+"
return result
a) Viết bộ test đạt 100% statement coverage với số test case ít nhất. b) Viết bộ test đạt 100% branch coverage. c) Tính Cyclomatic Complexity của hàm. (Gợi ý: đếm điểm rẽ nhánh + 1.)Bài 2 — Săn bug bằng branch. Lấy một hàm bất kỳ trong dự án của bạn (hoặc một hàm tính giá/phí/giảm giá). Chạy công cụ đo coverage (pytest-cov / JaCoCo / nyc). Ghi lại các dòng đỏ. Với mỗi dòng đỏ, đặt câu hỏi: "vì sao chưa có test nào chạm tới đây, và nếu chạm thì có bug không?".
Bài 3 — MC/DC. Cho điều kiện if (A and B) or C:. Hãy liệt kê bộ test tối thiểu để đạt MC/DC — tức chứng minh mỗi biến A, B, C đều tác động độc lập tới kết quả. (Gợi ý: cần khoảng 4 test case cho 3 điều kiện.)
Tóm tắt
White Box Testing là kỹ thuật thiết kế test dựa trên cấu trúc bên trong của mã nguồn, đo bằng các tiêu chí coverage với sức mạnh tăng dần: Statement < Branch/Decision < Condition < MC/DC < Path. Statement coverage chỉ đảm bảo mỗi dòng được chạy — dễ đạt nhưng bỏ sót lỗi logic và lỗi biên; branch coverage đảm bảo mỗi nhánh True/False đều được test và là mục tiêu thực tế nhất (70–85%) cho code nghiệp vụ; MC/DC dành cho logic rủi ro cao như tài chính, an toàn.
Ba câu chuyện — PayZo với cái bẫy statement 100% mà vẫn lọt bug biên, ShopViet dùng Cyclomatic Complexity làm tín hiệu refactor, và ngân hàng số dùng MC/DC để phát hiện lỗi auto-duyệt khách VIP nợ xấu — đều dạy chung một điều: coverage là công cụ, không phải mục tiêu. Con số cao không thay thế được assertion đúng và tư duy rủi ro. Là một QA hướng leadership, hãy dùng White Box để nói ngôn ngữ có bằng chứng, chọn mức coverage tương xứng với rủi ro của module, và luôn kết hợp nó với Black Box để test vừa đủ nhánh code vừa đúng yêu cầu.