Vấn đề: Test xanh lá, production vẫn cháy
Đã mấy lần mình gặp cái cảnh này: CI pipeline xanh lét, coverage 95%+, cả team review xong merge vào main — rồi production phát sinh bug ngay chức năng vừa được test kỹ. Cả team nhìn nhau không hiểu tại sao.
Sau vài lần debug và đào sâu vào các test case, mình nhận ra vấn đề không phải ở thiếu test, mà ở chất lượng của các test case đó. Test chạy qua code nhưng không thực sự kiểm tra logic bên trong.
Lấy ví dụ cụ thể nhất mình từng gặp — một hàm tính giảm giá:
def calculate_discount(price: float, quantity: int) -> float:
"""Giảm 10% nếu mua từ 5 sản phẩm trở lên."""
if quantity >= 5:
return price * 0.9
return price
Test của teammate:
def test_discount_applied():
assert calculate_discount(100.0, 10) == 90.0
def test_no_discount():
assert calculate_discount(100.0, 2) == 100.0
Coverage báo 100%. Nhưng thử đổi >= thành > trong hàm — cả hai test vẫn pass. Nghĩa là nếu developer vô tình viết sai điều kiện biên, không có gì bắt được.
Tại sao coverage cao vẫn không phản ánh đủ chất lượng test?
Coverage tool như pytest-cov chỉ đo một thứ duy nhất: dòng code nào đã được thực thi trong quá trình chạy test. Không hơn không kém.
Một test case có thể chạy qua một hàm 10 dòng mà không assert gì có ý nghĩa — coverage vẫn 100%. Đây gọi là weak test: test tồn tại để cho coverage xanh, không phải để bắt bug.
Ba vấn đề mình hay gặp nhất:
- Thiếu test case cho điều kiện biên (boundary condition)
- Assert quá chung chung, không kiểm tra giá trị cụ thể
- Test viết sau khi biết code làm gì — vô tình viết test “fit” với implementation thay vì với requirement
Coverage chỉ đo dòng code được thực thi, không đo assertion có thực sự kiểm tra gì không — đó là giới hạn cơ bản của nó.
Mutation Testing — lớp kiểm tra thứ hai cho bộ test
Ý tưởng của mutation testing thực ra đơn giản: tự động thay đổi code nguồn theo các pattern định sẵn — đổi >= thành >, True thành False, xóa một điều kiện, thay đổi phép toán số học. Sau mỗi lần mutate như vậy, toàn bộ test suite chạy lại để kiểm tra xem test có bắt được sai sót không.
- Killed mutation: test fail sau khi mutate → test tốt, bắt được sự thay đổi
- Survived mutation: test vẫn pass dù code đã bị sai → test yếu, bỏ sót lỗi
Mutation score = (số mutation bị killed) / (tổng số mutation). Score càng cao, bộ test càng chắc chắn.
Dùng mutmut — Mutation Testing tool cho Python
Trong Python, mutmut là lựa chọn được dùng nhiều nhất cho mutation testing. Không cần cấu hình thêm gì để bắt đầu — nó tự detect pytest và chạy ngay.
Cài đặt
pip install mutmut pytest
Cấu trúc project mẫu
my_project/
├── order.py
├── tests/
│ └── test_order.py
└── setup.cfg
File order.py:
# order.py
def calculate_discount(price: float, quantity: int) -> float:
"""Giảm 10% nếu mua từ 5 sản phẩm trở lên."""
if quantity >= 5:
return price * 0.9
return price
def validate_email(email: str) -> bool:
"""Kiểm tra email hợp lệ cơ bản."""
return "@" in email and "." in email.split("@")[-1]
File tests/test_order.py — bộ test ban đầu (yếu):
from order import calculate_discount, validate_email
def test_discount_applied():
assert calculate_discount(100.0, 10) == 90.0
def test_no_discount():
assert calculate_discount(100.0, 2) == 100.0
def test_valid_email():
assert validate_email("[email protected]") == True
def test_invalid_email():
assert validate_email("notanemail") == False
Chạy mutmut lần đầu
# Chạy mutation testing
mutmut run
# Xem kết quả tổng quan
mutmut results
Output mẫu:
⠋ 14/14 🎉 10 ⏰ 0 🤔 0 🙁 4 🔇 0
🙁 Survived mutations:
---- Mutant #4 ----
--- order.py
+++ order.py
@@ -2,7 +2,7 @@
def calculate_discount(price: float, quantity: int) -> float:
- if quantity >= 5:
+ if quantity > 5:
return price * 0.9
4 mutations sống sót — mutmut vừa chỉ ra chính xác test đang thiếu case nào. Mutation #4 cho thấy bộ test không bắt được khi thay >= thành >.
Xem chi tiết từng mutation sống sót
# Xem mutation cụ thể theo ID
mutmut show 4
# Xem toàn bộ survived mutations dạng diff
mutmut results --no-colour
Fix test dựa trên gợi ý của mutmut
def test_discount_boundary():
# Test điều kiện biên: đúng 5 sản phẩm
assert calculate_discount(100.0, 5) == 90.0
# Test sát biên dưới: 4 sản phẩm chưa đủ
assert calculate_discount(100.0, 4) == 100.0
def test_valid_email_formats():
assert validate_email("[email protected]") == True
assert validate_email("[email protected]") == True
# Thiếu domain sau @
assert validate_email("user@nodot") == False
# Không có @
assert validate_email("notanemail.com") == False
Chạy lại sau khi bổ sung test — mutation score tăng lên rõ rệt.
Cách tốt nhất để tích hợp mutmut vào workflow thực tế
Cấu hình qua setup.cfg
[mutmut]
paths_to_mutate=src/
backup=False
runner=python -m pytest
tests_dir=tests/
Chạy có chọn lọc — tiết kiệm thời gian
# Chỉ mutate file đang sửa
mutmut run --paths-to-mutate src/order.py
# Chạy lại survived mutations sau khi fix test (không mutate lại từ đầu)
mutmut run --rerun-all
# Xuất HTML report để xem visual
mutmut html
open html/index.html
Tích hợp vào GitHub Actions
# .github/workflows/mutation-test.yml
name: Mutation Testing
on:
pull_request:
paths: ['src/**/*.py', 'tests/**/*.py']
jobs:
mutmut:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- run: pip install mutmut pytest
- run: mutmut run --paths-to-mutate src/
- run: mutmut results
mutmut không cần chạy trên toàn bộ codebase mỗi lần — cấu hình paths filter ở trên giúp nó chỉ trigger khi đúng file liên quan thay đổi. Codebase lớn mà mutate toàn bộ mỗi PR dễ mất 20–30 phút; chạy theo path còn dưới 5 phút cho đa số trường hợp.
Một số mẹo từ kinh nghiệm thực tế
- Không cần nhắm 100% mutation score — 70–80% là hợp lý. Một số mutation trivial (thay đổi string trong log, comment) không đáng bỏ công test.
- Bắt đầu từ module quan trọng nhất — chạy thử trên một file trước khi áp toàn bộ project. Kết quả đầu tiên thường bất ngờ theo chiều tệ hơn bạn tưởng.
- mutmut chậm hơn pytest bình thường — mỗi mutation là một lần chạy toàn bộ test suite. Project có 200+ test function, mutmut có thể mất 10–30 phút tùy số mutation phát sinh. Lên lịch chạy CI qua đêm, hoặc luôn dùng
--paths-to-mutate. - Kết hợp với pytest-cov — coverage đảm bảo code được chạy qua, mutmut đảm bảo assertion có ý nghĩa. Hai công cụ bổ sung nhau, không thay thế nhau.
À, nhân tiện hàm validate_email ở trên — với regex phức tạp hơn (kiểm tra format email đầy đủ theo RFC), trước khi viết test mình hay verify pattern trước trên toolcraft.app/vi/tools/developer/regex-tester — chạy thẳng trên trình duyệt, không cần setup môi trường gì. Xác nhận regex đúng với các test case mẫu trước, rồi mới đưa vào assertion trong pytest. Tránh viết test dựa trên một regex sai từ đầu, sau đó mutmut lại report survived vì cả code lẫn test đều sai theo cùng một hướng.
Tổng kết
Coverage cao cho thấy code đã được chạy qua — không chứng minh được bộ test thực sự kiểm tra logic. Mutation testing với mutmut chỉ ra đúng chỗ bộ test đang hổng — không phải cảm tính, mà bằng diff cụ thể cho từng mutation.
Thử ngay trên một module nhỏ trong project hiện tại. Chạy mutmut run lần đầu và xem bao nhiêu mutation sống sót qua bộ test tưởng là kỹ của mình — đảm bảo sẽ có vài cái bất ngờ.
