Đừng tin tưởng tuyệt đối vào Code Coverage 100%: Tìm kiếm lỗ hổng tiềm ẩn với Stryker Mutation Testing

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Ảo tưởng về dấu tích xanh

Vài tháng trước, tôi từng tham gia một đội ngũ xây dựng mô-đun thanh toán quan trọng cho một startup fintech. Chúng tôi vô cùng tự hào với độ bao phủ unit test (coverage) lên đến 95%. Cả đội cảm thấy hệ thống của mình gần như không thể sai sót. Tuy nhiên, chỉ sáu ngày sau khi triển khai, một lỗi logic đã lọt qua, cho phép người dùng thực hiện các giao dịch âm trong một số điều kiện cụ thể. Các bài test của chúng tôi vẫn vượt qua, báo cáo coverage là một màu xanh mướt, nhưng logic thực tế đã bị hỏng hoàn toàn.

Đây là một cái bẫy phổ biến. Nhiều lập trình viên coi code coverage là thước đo cuối cùng của chất lượng. Nhưng coverage chỉ theo dõi những dòng code nào được thực thi trong quá trình chạy test. Nó không nói lên việc các bài test của bạn có thực sự kiểm chứng được logic hay không. Bạn có thể đạt 100% coverage mà không cần một dòng assertion nào, và các công cụ báo cáo vẫn sẽ nói rằng mọi thứ đều hoàn hảo.

Vấn đề thực sự với các chỉ số tiêu chuẩn

Line coverage là một chỉ số nông cạn. Hầu hết các công cụ chỉ đơn giản là gắn mã theo dõi (instrument) để xem một dòng code có được chạm đến hay không. Hãy tưởng tượng một hàm tính toán mức chiết khấu thuế phức tạp. Nếu bài test của bạn gọi hàm đó, dòng code đó sẽ được đánh dấu là ‘đã bao phủ’. Nhưng chuyện gì sẽ xảy ra nếu bạn quên assert (kiểm tra) kết quả đầu ra? Hoặc nếu assertion của bạn quá mơ hồ đến mức nó vẫn pass ngay cả khi thuế suất nhảy từ 10% lên 80%?

Con người luôn có những điểm mù tự nhiên. Chúng ta thường viết test để xác nhận những gì chúng ta nghĩ là code đang thực hiện, thay vì cố gắng phá vỡ nó. Điều này dẫn đến những bài test ‘yếu’. Những bài test này tồn tại trong mã nguồn nhưng không mang lại sự bảo vệ thực sự trước các lỗi hồi quy (regression) hoặc các lỗi logic tinh vi.

Một cách tiếp cận tốt hơn: Mutation Testing

Để khắc phục điều này, chúng ta cần tiến xa hơn việc review thủ công. Mặc dù code review rất hữu ích, nhưng chúng chậm chạp và con người dễ dàng bỏ sót các trường hợp biên trong những kho mã nguồn (repository) hàng chục nghìn dòng. Property-based testing là một lựa chọn khác, mặc dù nó đòi hỏi quá trình học hỏi khá dốc. Mutation testing mang đến một giải pháp thay thế tự động và nghiêm ngặt hơn.

Hãy coi mutation testing như một bài ‘kiểm tra sức chịu tải’ (stress test) cho bộ test suite của bạn. Thay vì kiểm tra mã nguồn, nó cố tình làm hỏng mã nguồn để xem các bài test có nhận ra sự phá hoại đó hay không. Nếu một công cụ thay đổi dấu > thành >= mà các bài test của bạn vẫn vượt qua, chứng tỏ bộ test của bạn chưa đủ chặt chẽ. ‘Mutant’ (biến dị) đó đã sống sót, báo hiệu một lỗ hổng trong mạng lưới an toàn của bạn.

Bắt đầu với Stryker Mutator

Đối với những người làm việc với JavaScript, TypeScript, C#, hoặc Scala, Stryker Mutator là tiêu chuẩn của ngành. Nó tự động hóa toàn bộ quá trình tạo ra các mutant và chạy chúng đối với các bài test của bạn. Khi tôi áp dụng công cụ này vào quy trình làm việc, chúng tôi đã phát hiện ra ba lỗi logic nghiêm trọng ngay trong giờ đầu tiên—những lỗi mà các công cụ coverage thông thường đã bỏ qua trong nhiều tháng.

Bước 1: Cài đặt nhanh

Trong môi trường Node.js, việc thiết lập mất chưa đầy hai phút. Cài đặt Stryker core và runner cho framework cụ thể của bạn:

npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner

(Lưu ý: Bạn có thể thay thế jest-runner bằng mocha-runner hoặc karma-runner tùy nhu cầu.)

Bước 2: Khởi tạo

Chạy trình hướng dẫn khởi tạo để tạo cấu hình. Stryker sẽ tự động nhận diện môi trường của bạn và đặt một vài câu hỏi cơ bản:

npx stryker init

Lệnh này sẽ tạo ra file stryker.config.json. Đối với một dự án TypeScript tiêu chuẩn, cấu hình của bạn có thể trông như thế này:

{
  "$schema": "https://schema.stryker-mutator.io/config/stryker-config.schema.json",
  "packageManager": "npm",
  "reporters": ["html", "clear-text"],
  "testRunner": "jest",
  "coverageAnalysis": "perTest",
  "mutate": ["src/**/*.ts", "!src/**/*.spec.ts"]
}

Bước 3: Săn lùng các Mutant

Kích hoạt engine đột biến bằng một lệnh duy nhất:

npx stryker run

Stryker trước tiên sẽ đảm bảo các bài test của bạn vượt qua trên mã nguồn gốc. Sau đó, nó bắt đầu tạo ra các ‘mutant’—những lỗi nhỏ được cố tình đưa vào logic. Nó có thể tráo đổi + thành -, đổi true thành false, hoặc xóa nội dung của một hàm void. Nếu các bài test của bạn thất bại, mutant đó bị tiêu diệt (Killed) (đây là điều tốt). Nếu các bài test vẫn vượt qua, mutant đó sống sót (Survived) (đây là một dấu hiệu cảnh báo).

Đọc kết quả

Sau khi chạy xong, Stryker sẽ tạo ra một báo cáo HTML tương tác. Khi tôi chạy lệnh này trên dự án có ‘95% coverage’ của mình, kết quả thật đáng kinh ngạc. Chúng tôi tìm thấy vài mutant sống sót trong logic giao dịch cốt lõi.

Hãy xem xét đoạn kiểm tra biên này từ dự án của chúng tôi:

// Mã nguồn gốc
if (userAge < 18) {
    throw new Error("Chưa đủ tuổi");
}

Stryker đã đổi nó thành userAge <= 18. Các bài test của chúng tôi vẫn vượt qua vì chúng tôi chỉ có các trường hợp thử nghiệm cho 1721, nhưng chưa bao giờ test chính xác tại biên 18. Mutant đã sống sót. Nó cho chúng tôi thấy chính xác nơi mà việc kiểm thử còn hời hợt. Các công cụ coverage tiêu chuẩn không bao giờ có thể cung cấp mức độ chi tiết này.

Các kinh nghiệm thực tế

Mutation testing tiêu tốn khá nhiều tài nguyên tính toán. Chạy nó trên một hệ thống monolith khổng lồ có thể mất 30 phút hoặc hơn. Để giữ cho pipeline của bạn luôn nhanh chóng, hãy làm theo các chiến lược sau:

  1. Đột biến có mục tiêu (Targeted Mutation): Trong các pipeline CI/CD, hãy sử dụng cờ --mutate để chỉ kiểm tra các file đã thay đổi trong Pull Request hiện tại. Điều này giảm thời gian chạy từ vài phút xuống còn vài giây.
  2. Bỏ qua mã Boilerplate: Đừng lãng phí chu kỳ CPU cho các DTO, file cấu hình, hoặc các hàm getter/setter đơn giản. Hãy tập trung nỗ lực vào các logic nghiệp vụ phức tạp.
  3. Áp dụng Mutation Score: Thiết lập một ngưỡng tối thiểu, ví dụ 80%. Nếu điểm đột biến (mutation score) thấp hơn con số này, hãy làm cho build thất bại giống như cách bạn làm với một unit test bị hỏng.

Lời kết

Việc chuyển từ ‘Line Coverage’ sang ‘Mutation Score’ đã thay đổi hoàn toàn cách tôi tiếp cận chất lượng phần mềm. Nó chuyển trọng tâm từ câu hỏi “chúng ta đã viết đủ test chưa?” sang “các bài test của chúng ta hiệu quả đến mức nào?”. Mặc dù đòi hỏi nhiều năng lực xử lý hơn, nhưng sự an tâm mà nó mang lại là vô giá. Nếu bạn muốn một hệ thống thực sự ổn định, hãy ngừng chạy theo những thanh biểu đồ xanh và bắt đầu tiêu diệt các mutant.

Share: