Khi 98% Code Coverage vẫn để lọt bug production
Hơn sáu tháng trước, team mình refactor module thanh toán cho một sàn thương mại điện tử với hơn 50.000 dòng TypeScript. Mở báo cáo Jest lên, cả nhóm thở phào nhẹ nhõm: Line coverage đạt 98%, branch coverage chạm mốc 94%. Mọi người đều đinh ninh rằng codebase đã được bảo vệ tuyệt đối.
Nhưng đời không như mơ. Ngay đợt release đầu tiên, một lỗi logic nghiêm trọng trôi thẳng lên production: Khách hàng VIP được áp mã giảm giá kép, khiến đơn hàng 2 triệu đồng bị tính về 0 đồng. Trớ trêu là trên CI pipeline, toàn bộ 420 test case vẫn xanh rì.
Đào sâu vào từng file test, mình mới nhận ra lỗ hổng chết người: Code coverage chỉ ghi nhận dòng code đó có được thực thi hay không. Nó hoàn toàn mù tịt về việc câu lệnh expect() hay assert() có đủ sức bắt lỗi khi logic bị đảo lộn.
Nhiều test case chỉ gọi hàm lấy lệ, mock dữ liệu vô tội vạ, thậm chí không assert kết quả trả về chỉ để kéo KPI coverage cho đẹp báo cáo. Lúc đó, mình quyết định đưa Mutation Testing cùng công cụ Stryker vào dự án để bóc trần chất lượng thực sự của test suite.
Khái niệm cốt lõi: Mutation Testing vận hành ra sao?
Nếu Unit Test kiểm tra code chạy đúng nghiệp vụ hay không, thì Mutation Testing làm ngược lại: Nó cố tình phá hỏng code để xem test suite có phát hiện ra hay không.
Quy trình gồm 3 bước rõ ràng:
- Tạo đột biến (Mutants): Stryker can thiệp vào AST (Abstract Syntax Tree) của TypeScript và tạo ra các lỗi nhỏ có chủ đích. Ví dụ: đổi
>thành>=, đổia + bthànha - b, xóa một lệnh gọi hàm hoặc biếntruethànhfalse. Mỗi bản code lỗi này là một Mutant. - Chạy test suite: Stryker chạy lại các unit test liên quan đến đoạn code vừa bị can thiệp.
- Đánh giá kết quả:
- Killed (Bị tiêu diệt): Test suite bị FAIL. Đây là kết quả tốt. Code vừa hỏng thì test đã bắt được ngay.
- Survived (Sống sót): Code bị sửa sai logic nhưng test vẫn PASS ngon lành. Đây là bằng chứng test case của bạn đang bỏ lọt lỗi hoặc viết assertion quá hời hợt.
- Timeout: Mutant tạo ra vòng lặp vô hạn, test chạy vượt ngưỡng timeout (thường được tính là killed).
Chỉ số cốt lõi thu được là Mutation Score:
Mutation Score = (Số Mutants bị Killed / Tổng số Mutants) * 100%
Một dự án có thể khoe 95% Code Coverage, nhưng Mutation Score thực tế đôi khi chỉ đạt 35% – 40%. Nghĩa là hơn một nửa logic nghiệp vụ chưa từng được test bảo vệ nghiêm túc.
Thực hành: Cài đặt và cấu hình Stryker trong TypeScript
Dưới đây là các bước thực tế mình áp dụng trên codebase dùng Vitest (bạn hoàn toàn có thể dùng với Jest).
Bước 1: Tạo module mẫu để thấy rõ lỗ hổng test
Giả sử chúng ta có hàm tính chiết khấu theo hạng thành viên tại src/discount.ts:
export interface Customer {
tier: 'SILVER' | 'GOLD' | 'PLATINUM';
yearsActive: number;
}
export function calculateDiscount(orderAmount: number, customer: Customer): number {
if (orderAmount <= 0) {
return 0;
}
let discountRate = 0;
if (customer.tier === 'PLATINUM' || customer.yearsActive >= 5) {
discountRate = 0.2; // Giảm 20%
} else if (customer.tier === 'GOLD') {
discountRate = 0.1; // Giảm 10%
}
return orderAmount * discountRate;
}
Và đây là file test tests/discount.test.ts được viết vội để lấy coverage:
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from '../src/discount';
describe('calculateDiscount', () => {
it('chạy qua các nhánh logic', () => {
// Test nhánh orderAmount <= 0
expect(calculateDiscount(-10, { tier: 'SILVER', yearsActive: 1 })).toBe(0);
// Test nhánh PLATINUM
const platinumResult = calculateDiscount(100, { tier: 'PLATINUM', yearsActive: 1 });
expect(platinumResult).toBeGreaterThan(0);
// Test nhánh GOLD
const goldResult = calculateDiscount(100, { tier: 'GOLD', yearsActive: 2 });
expect(goldResult).toBe(10);
});
});
Chạy vitest run --coverage, báo cáo vẫn hiển thị 100% xanh mướt. Tuy nhiên, nhánh customer.yearsActive >= 5 chưa hề được kiểm tra riêng cho khách không phải PLATINUM. Tệ hơn, assertion của PLATINUM chỉ là toBeGreaterThan(0) thay vì kiểm tra đúng con số 20.
Bước 2: Cài đặt Stryker Mutator
Cài đặt gói core cùng runner tương ứng:
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
Nếu dự án dùng Jest, bạn đổi sang @stryker-mutator/jest-runner.
Bước 3: Thiết lập file cấu hình stryker.config.json
Tạo file stryker.config.json tại thư mục gốc:
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"packageManager": "npm",
"reporters": ["html", "clear-text", "progress"],
"testRunner": "vitest",
"coverageAnalysis": "perTest",
"mutate": [
"src/**/*.ts",
"!src/**/*.d.ts",
"!src/**/index.ts"
],
"thresholds": {
"high": 80,
"low": 60,
"break": 60
}
}
Ba thiết lập cần đặc biệt chú ý:
coverageAnalysis: "perTest": Tùy chọn tối ưu bắt buộc. Stryker chỉ chạy lại test nào phủ trúng dòng code bị mutant. Thời gian chạy nhờ đó giảm từ 15 phút xuống dưới 2 phút.mutate: Chỉ gom các file chứa business logic, loại bỏ hoàn toàn types, interfaces hoặc barrel export.thresholds.break: Nếu Mutation Score rơi xuống dưới 60%, tiến trình trả về mã lỗi 1 để chặn merge code.
Bước 4: Chạy thử và phân tích báo cáo lỗi
Thêm script vào package.json:
"scripts": {
"test:mutate": "stryker run"
}
Thực thi lệnh:
npm run test:mutate
Terminal lập tức chỉ điểm hai mutant thoát chết:
#1. [Survived] EqualityOperator
src/discount.ts:12:47
- customer.yearsActive >= 5
+ customer.yearsActive > 5
#2. [Survived] BinaryExpression
src/discount.ts:13:20
- discountRate = 0.2;
+ discountRate = 0.0;
Stryker đã sửa >= 5 thành > 5 và đổi mức giảm giá 0.2 thành 0.0. Toàn bộ test suite vẫn PASS. Code bị phá hỏng mà test không hề phát hiện ra.
Bước 5: Viết lại test để hạ gục các mutant sống sót
Chúng ta bổ sung các test case bao phủ đúng giá trị biên vào tests/discount.test.ts:
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from '../src/discount';
describe('calculateDiscount', () => {
it('trả về 0 khi giá trị đơn hàng nhỏ hơn hoặc bằng 0', () => {
expect(calculateDiscount(0, { tier: 'SILVER', yearsActive: 1 })).toBe(0);
expect(calculateDiscount(-100, { tier: 'SILVER', yearsActive: 1 })).toBe(0);
});
it('áp dụng mức giảm chính xác 20% cho khách PLATINUM', () => {
const result = calculateDiscount(100, { tier: 'PLATINUM', yearsActive: 1 });
expect(result).toBe(20); // Kiểm tra chính xác giá trị, không dùng toBeGreaterThan
});
it('áp dụng mức giảm 20% cho khách thâm niên từ 5 năm trở lên dù không phải PLATINUM', () => {
// Kiểm tra giá trị biên tại mốc 5 năm
expect(calculateDiscount(100, { tier: 'SILVER', yearsActive: 5 })).toBe(20);
// Khách 4 năm không được hưởng ưu đãi này
expect(calculateDiscount(100, { tier: 'SILVER', yearsActive: 4 })).toBe(0);
});
it('áp dụng mức giảm 10% cho khách GOLD dưới 5 năm', () => {
expect(calculateDiscount(100, { tier: 'GOLD', yearsActive: 2 })).toBe(10);
});
});
Chạy lại npm run test:mutate:
Mutation score based on treated mutants: 100.00%
All mutants were killed!
Mở file reports/mutation/mutation.html, toàn bộ cây thư mục đã chuyển sang màu xanh lá. Mọi biến thể logic sai lệch đều đã bị test tóm gọn.
Kinh nghiệm thực chiến sau 6 tháng vận hành
Đưa Stryker vào pipeline thực tế của team, mình rút ra 3 bài học đắt giá:
- Đừng quét toàn bộ repository: Nhược điểm lớn nhất của Mutation Testing là ngốn tài nguyên. Chạy trên 50.000 dòng code có thể mất hơn 40 phút và đẩy CPU lên 100%. Rất lãng phí.
- Khoanh vùng Core Business Logic: Chỉ cấu hình Stryker cho các thư mục chứa nghiệp vụ trọng yếu như
src/modules/pricing,src/modules/payment, haysrc/modules/auth. Giới hạn này giúp test suite chạy trọn vẹn chỉ trong 90 giây. - Tích hợp thông minh vào CI: Thay vì chạy trên mọi commit, team mình dùng cờ
--since origin/maintrên GitHub Actions. Stryker chỉ quét những file có thay đổi trong Pull Request, giúp build time mỗi PR chỉ tăng thêm chưa đầy 2 phút.
Code Coverage cao chỉ chứng minh bạn chăm chỉ gõ code test. Mutation Testing mới là thước đo thực sự cho thấy các bài test đó có bảo vệ được hệ thống khi có sự cố hay không.
