Your Tests Pass, But Do They Actually Test Anything? A Practical Guide to Mutation Testing with mutmut and StrykerJS
Tuần trước mình review một PR có coverage 94%, CI xanh, test chạy chưa tới 10 giây. Nhìn rất ổn.
Rồi mình thử đổi >= thành > trong hàm tính phí ship. Test vẫn xanh. Đổi return total thành return 0, test vẫn xanh. Lúc đó mình nhận ra coverage chỉ cho biết dòng code nào đã được chạy, chứ không cho biết test có kiểm tra kết quả hay không.
Bài viết đang hot trên Dev.to "Four Ways a Test Can Lie to You" nói đúng vấn đề này. Hôm nay mình chia sẻ cách mình dùng mutation testing để bắt những test "nói dối", với Python (mutmut) và JavaScript/TypeScript (StrykerJS).
Mutation testing là gì, và tại sao coverage không đủ
Ý tưởng rất đơn giản: tool tự động tạo ra các phiên bản lỗi nhỏ của code (gọi là mutant), ví dụ đổi + thành -, < thành <=, True thành False, xóa một câu lệnh. Sau đó nó chạy test suite với từng mutant:
- Test fail → mutant bị killed. Tốt, test của bạn phát hiện được lỗi.
- Test vẫn pass → mutant survived. Có một bug tiềm ẩn mà test không bắt được.
Mutation score = killed / tổng mutant. Con số này phản ánh chất lượng test sát hơn coverage rất nhiều.
flowchart LR A[Source code] --> B[Mutation engine] B --> C1[Mutant 1: >= thành >] B --> C2[Mutant 2: + thành -] B --> C3[Mutant 3: xóa return] C1 --> D[Chạy test suite] C2 --> D C3 --> D D -->|Test fail| E[Killed ✅] D -->|Test pass| F[Survived ⚠️] F --> G[Viết thêm assertion]
Những kiểu test "nói dối" mà mutation testing hay bắt được:
- Test không có assertion: chỉ gọi hàm, không kiểm tra kết quả.
- Assertion quá lỏng: kiểu
assert result is not Nonehoặcexpect(x).toBeTruthy(). - Thiếu boundary case: không test đúng giá trị biên như
0,100, chuỗi rỗng. - Mock quá đà: mock luôn cả logic cần test, nên test chỉ đang test cái mock.
Thực hành với Python: mutmut
Giả sử có hàm tính phí ship như sau:
# shipping.py
def shipping_fee(total: int, is_member: bool) -> int:
if total >= 500_000:
return 0
fee = 30_000
if is_member:
fee = fee // 2
return fee
Và một test trông "đủ coverage":
# test_shipping.py
from shipping import shipping_fee
def test_free_shipping():
assert shipping_fee(1_000_000, False) == 0
def test_member_discount():
assert shipping_fee(100_000, True) > 0
def test_normal():
assert shipping_fee(100_000, False) is not None
Coverage đạt 100%. Giờ cài mutmut (mình dùng bản 3.x) và chạy thử:
pip install mutmut pytest
# Cấu hình trong pyproject.toml
cat >> pyproject.toml << 'EOF'
[tool.mutmut]
paths_to_mutate = ["shipping.py"]
tests_dir = ["test_shipping.py"]
EOF
mutmut run
mutmut results # liệt kê mutant survived
mutmut show shipping.x_shipping_fee__mutmut_2 # xem diff của 1 mutant
Kết quả sẽ có khá nhiều mutant sống sót:
total >= 500_000→total > 500_000: survived, vì không có test nào dùng đúng500_000.fee // 2→fee / 2hoặcfee * 2: survived, vì test chỉ check> 0.fee = 30_000→fee = 30_001: survived, vì test cuối chỉ checkis not None.
Sửa test lại cho đàng hoàng:
import pytest
from shipping import shipping_fee
@pytest.mark.parametrize("total,member,expected", [
(500_000, False, 0), # boundary chính xác
(499_999, False, 30_000), # ngay dưới boundary
(100_000, True, 15_000), # member giảm 50%
(100_000, False, 30_000),
])
def test_shipping_fee(total, member, expected):
assert shipping_fee(total, member) == expected
Chạy lại mutmut run, mutation score lên gần 100%. Code không đổi một dòng, nhưng test giờ mới thật sự có giá trị.
Thực hành với JavaScript/TypeScript: StrykerJS
Bên JS mình dùng StrykerJS (bản 8.x trở lên), hỗ trợ Jest, Vitest, Mocha. Khởi tạo bằng:
npm init stryker@latest # chọn test runner (vd: vitest), reporter html + clear-text
npx stryker run
File config mình hay dùng cho dự án thật:
// stryker.config.mjs
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
export default {
testRunner: 'vitest',
mutate: ['src/**/*.ts', '!src/**/*.test.ts', '!src/generated/**'],
reporters: ['html', 'clear-text', 'progress'],
coverageAnalysis: 'perTest', // chỉ chạy test liên quan tới mutant
incremental: true, // cache kết quả, lần sau chỉ chạy phần thay đổi
concurrency: 4,
thresholds: { high: 80, low: 60, break: 50 },
};
Hai option quan trọng nhất để không bị chậm:
coverageAnalysis: 'perTest': Stryker biết test nào chạy qua dòng nào, nên mỗi mutant chỉ chạy vài test thay vì cả suite.incremental: true: lưu kết quả vàoreports/stryker-incremental.json, lần sau chỉ test lại mutant ở code vừa sửa.
Với codebase vài chục nghìn dòng, thời gian giảm từ khoảng 40 phút xuống còn vài phút. thresholds.break: 50 nghĩa là nếu mutation score dưới 50% thì Stryker trả exit code khác 0 và CI fail.
Đưa vào CI mà không làm team phát điên
Mutation testing tốn tài nguyên: N mutant tương đương N lần chạy test. Đừng chạy full trên mỗi commit. Flow mình đang dùng:
sequenceDiagram participant Dev participant CI as CI (Pull Request) participant Nightly as Nightly Job Dev->>CI: Push PR CI->>CI: Unit test + coverage CI->>CI: Mutation test chỉ trên file thay đổi CI-->>Dev: Comment survived mutants Nightly->>Nightly: Full mutation run (incremental) Nightly-->>Dev: Báo cáo HTML + trend score
Với PR, chỉ mutate file thay đổi:
#!/usr/bin/env bash
set -euo pipefail
CHANGED=$(git diff --name-only origin/main...HEAD -- 'src/*.ts' | grep -v '.test.ts' | paste -sd, -)
if [ -z "$CHANGED" ]; then
echo "Không có file source thay đổi, bỏ qua mutation test"
exit 0
fi
npx stryker run --mutate "$CHANGED"
Vài kinh nghiệm thực tế:
- Không đặt mục tiêu 100%. Sẽ luôn có equivalent mutant, tức mutant cho ra hành vi giống hệt code gốc (vd: đổi điều kiện trong code chỉ để log). Score khoảng 70-85% cho business logic là rất tốt rồi.
- Ưu tiên module quan trọng: tính tiền, phân quyền, validate input. Không cần mutate UI component hay code glue.
- Đọc survived mutant như đọc code review. Mỗi mutant sống sót là một câu hỏi: "Nếu ai đó vô tình sửa dòng này, có test nào phát hiện không?"
- Test phải nhanh và deterministic. Test flaky làm kết quả mutation sai hoàn toàn, nên phải fix flaky test trước.
Kết luận
Coverage cho biết test đã chạy qua những đâu, còn mutation testing cho biết test có bảo vệ được code hay không. Đây cũng là kỹ năng ngày càng quan trọng khi AI viết code và cả test ngày càng nhiều: test do AI sinh ra rất hay có assertion lỏng kiểu toBeDefined(), nhìn qua thì đủ coverage nhưng gần như không bắt được bug.
Những việc bạn có thể làm ngay tuần này:
- Chọn một module quan trọng nhất (tính tiền, auth), chạy
mutmut runhoặcnpx stryker runriêng cho nó. - Đọc danh sách survived mutant, viết thêm test cho boundary value và đổi assertion lỏng thành so sánh giá trị chính xác.
- Bật
coverageAnalysis: 'perTest'vàincremental: trueđể thời gian chạy ở mức chấp nhận được. - Đưa mutation test vào CI chỉ cho file thay đổi ở PR, còn full run để nightly.
- Đặt
thresholds.breakthấp lúc đầu (khoảng 50%), rồi tăng dần theo từng sprint.
Test xanh không có nghĩa là code đúng. Mutation testing là cách rẻ nhất để biết test của bạn có đang "nói dối" hay không.
Comments
No comments yet. Start the discussion.