跳到正文
DEV Community· EME GUG·· 3 小時前AI 評分60

測試通過,但真的有測到問題嗎?使用 mutmut 和 StrykerJS 的變異測試實用指南

Your Tests Pass, But Do They Actually Test Anything? A Practical Guide to Mutation Testing with mutmut and StrykerJS

AI 導讀

作者分享以 Python 的 mutmut 和 JavaScript/TypeScript 的 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:

  1. Test không có assertion: chỉ gọi hàm, không kiểm tra kết quả.
  2. Assertion quá lỏng: kiểu assert result is not None hoặc expect(x).toBeTruthy().
  3. Thiếu boundary case: không test đúng giá trị biên như 0, 100, chuỗi rỗng.
  4. 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 đúng 500_000.
  • fee // 2 → fee / 2 hoặc fee * 2: survived, vì test chỉ check > 0.
  • fee = 30_000 → fee = 30_001: survived, vì test cuối chỉ check is 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ào reports/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:

  1. Chọn một module quan trọng nhất (tính tiền, auth), chạy mutmut run hoặc npx stryker run riêng cho nó.
  2. Đọ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.
  3. Bật coverageAnalysis: 'perTest' và incremental: true để thời gian chạy ở mức chấp nhận được.
  4. Đưa mutation test vào CI chỉ cho file thay đổi ở PR, còn full run để nightly.
  5. Đặt thresholds.break thấ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.

來源:DEV Community · dev.to