Test do AI viết đều pass nhưng một nửa mutant vẫn lọt qua
Nếu ai hỏi tôi kiểm chứng code do AI viết bằng cách nào, trước đây tôi có một câu trả lời rất đơn giản: chạy test.
Dự án của tôi có 451 test trong 84 file. Claude Code viết chúng, còn tôi review và merge. Mỗi lần test xanh, tôi thấy khá yên tâm.
Rồi một người bạn nhắc đến mutation testing. Tôi thử trên mười file trong src/lib đã có test. Công cụ tạo 550 thay đổi nhỏ trong code ứng dụng, rồi kiểm tra xem các test có phát hiện ra không.
| Phạm vi | Mutant | Killed | Survived | Không có coverage |
|---|---|---|---|---|
Mười file đã có test trong src/lib |
550 | 273 | 161 | 116 |
Các test phát hiện 49,6% số mutant đó. Có 161 mutant vẫn sống sót dù test đã chạy, và 116 mutant không có test nào bao phủ.
Điều này không có nghĩa là một nửa trong số 451 test của tôi vô dụng. Nó có nghĩa là gần một nửa các mutation được tạo trong mẫu này không bị phát hiện. Chừng đó đủ để tôi xem lại các test thực sự đang kiểm tra điều gì.
Có test chỉ kiểm tra chữ trong mã nguồn
Đây là một đoạn trong dự án của tôi, với mô tả test đã dịch sang tiếng Anh:
function source(relativePath: string): string {
return readFileSync(
fileURLToPath(new URL(`../${relativePath}`, import.meta.url)),
'utf8',
)
}
it('separates new time credits from the legacy user baseline', () => {
const codingTimeRepository = source(
'src/server/repository/coding-time-credit/index.ts',
)
expect(codingTimeRepository).toContain('userCodingTimeCreditLedger')
expect(codingTimeRepository).not.toContain('projectId')
})
Test này đọc file dưới dạng chuỗi rồi tìm từ. Nó không thực thi phép tính tín dụng thời gian, cũng không kiểm tra kết quả tính toán.
Tôi tìm thấy 62 test như vậy trong bảy file. Chúng có thể bảo đảm một quy ước, nhưng không chứng minh chương trình hoạt động đúng. Phần triển khai có thể trả về giá trị sai trong khi các kiểm tra này vẫn xanh.
Một số kiểu test khác cũng đáng xem kỹ hơn:
- Sao chép trực tiếp đầu ra hiện tại của phần triển khai làm giá trị kỳ vọng mà không kiểm tra xem đầu ra có đúng yêu cầu hay không.
- Viết lại thuật toán của code ứng dụng ngay trong test để tính kết quả kỳ vọng.
- Chỉ kiểm tra rằng mock đã được gọi. Dự án của tôi có 139 assertion
toHaveBeenCalled(); điều đáng hỏi là mỗi assertion thực sự chứng minh được gì về hành vi. - Dùng số lượng file test hoặc coverage làm bằng chứng rằng các quyết định quan trọng đều có assertion kiểm tra.
Những vấn đề này không chỉ xuất hiện với AI. Nhưng khi cùng một agent viết cả phần triển khai lẫn test trong một phiên làm việc, cả hai có thể cùng mắc một cách hiểu sai.
Nghiên cứu All Smoke, No Alarm năm 2026 phân tích 86.156 bản vá file test từ 33.596 pull request do agent tạo. Phân loại dựa trên cú pháp của nghiên cứu ghi nhận 80,2% bản vá có dấu hiệu yếu hoặc không có dấu hiệu rõ ràng của test oracle, tức tiêu chí xác định kết quả đúng. Đây là kết quả về các mẫu assertion quan sát được trong tập dữ liệu đó, không phải bằng chứng rằng 80,2% mọi test do AI viết đều vô dụng. Nó cho thấy vì sao đếm test không thể thay cho việc xem test đang kiểm tra gì. Nghiên cứu
Mutation testing kiểm tra xem thay đổi có làm test fail không
Công cụ mutation tạo các thay đổi nhỏ trong code ứng dụng: đổi < thành <=, đổi + thành -, ép một điều kiện thành false hoặc thay giá trị trả về.
Mỗi phiên bản đã sửa là một mutant. Công cụ chạy các test liên quan với phiên bản đó.
| Kết quả | Ý nghĩa |
|---|---|
| Killed | Ít nhất một test fail khi mutation có hiệu lực |
| Survived | Các test vẫn pass |
| No coverage | Không có test nào bao phủ đoạn code bị thay đổi |
Đó là ba trạng thái tôi tập trung xem. Công cụ còn báo timeout, mutation không hợp lệ và những trạng thái khác. Điểm tổng thể của Stryker tính số mutant được phát hiện trên số mutant hợp lệ; điểm dành cho phần code có coverage dùng mẫu số khác. Trạng thái và chỉ số của Stryker
Xem hàm sau:
function isUserOldEnough(user: { age: number }) {
return user.age >= 18
}
Nếu mutant đổi >= 18 thành > 18, các test dùng tuổi 17 và 19 vẫn pass. Một test cho đúng tuổi 18 mới phân biệt được hai phiên bản.
Coverage có thể cho tôi biết test đã chạy qua phép so sánh. Mutation testing giúp tôi tìm xem assertion có nhận ra thay đổi có ý nghĩa ở phép so sánh đó hay không.
Mười một test bỏ sót cả sáu thay đổi ở ranh giới
Trong lần chạy đầu tiên, tôi dùng StrykerJS với Vitest trong một dự án Next.js. Cấu hình khá ngắn:
{
"testRunner": "vitest",
"plugins": ["@stryker-mutator/vitest-runner"],
"mutate": ["src/lib/**/*.ts"],
"disableTypeChecks": false,
"incremental": true,
"reporters": ["clear-text", "html"]
}
Sau khi cài Stryker, Vitest runner tương ứng và chuẩn bị cấu hình test, tôi chạy:
npx stryker run
Lần chạy đầu tiên đó mất 1 phút 5 giây trong dự án của tôi.
disableTypeChecks: false ngăn Stryker chèn các chỉ thị @ts-nocheck như mặc định. Nó không khiến Vitest tự kiểm tra kiểu TypeScript; kiểm tra kiểu vẫn cần checker hoặc lệnh riêng. Cấu hình Stryker
Kết quả khiến tôi nhớ nhất đến từ kstRelativeTime, tiện ích tạo nhãn như “vừa xong”, “ba giờ trước” và “hôm qua”. Có mười một test chạy qua hàm này. Cả sáu mutant đổi < thành <= đều sống sót.
Tôi có test quanh các mốc thời gian, nhưng không có test ngay tại mốc. Không test nào hỏi điều gì phải xảy ra ở đúng 48 giờ. Một nhánh trả về số tuần thì hoàn toàn không có coverage.
Một số kết quả khác cũng khó bỏ qua:
- Đổi
+ 1thành- 1trong số thứ tự dùng cho chỉnh sửa đồng thời mà test vẫn pass. - Thay
if (local === remote)bằngif (false)trong logic gộp văn bản mà test vẫn pass. - Bộ phân tích ảnh Markdown chỉ tiêu diệt được sáu trong số 109 mutant.
Tất cả những file đó đều đã có test. CI vẫn xanh.
Các test đọc mã nguồn dưới dạng chuỗi lại gây một vấn đề khác. Instrumentation chèn thêm code để theo dõi quá trình chạy, nên test kiểm tra nguyên văn mã nguồn có thể fail chỉ vì công cụ thêm phần code hỗ trợ. Tôi loại các test đó khỏi lần chạy mutation. Tôi muốn test fail vì hành vi thay đổi, không phải vì cách công cụ viết phần instrumentation.
Vì sao điều này quan trọng khi agent viết test
Test cung cấp phản hồi cho coding agent. Assertion yếu có thể khiến agent nghĩ mình đã làm xong dù một hành vi quan trọng vẫn chưa được kiểm tra.
Mutation testing giúp tôi đưa ra yêu cầu cụ thể hơn câu “viết thêm test”. Tôi có thể chỉ vào một thay đổi còn sống sót và hỏi đầu vào nào phân biệt được hành vi gốc với hành vi sau mutation. Sau đó, tôi xem kết quả kỳ vọng có thực sự xuất phát từ yêu cầu hay không.
Automated Compliance Hardener của Meta dùng một cách tiếp cận liên quan ở quy mô khác. Hệ thống dùng LLM tạo mutant nhắm vào những mối quan tâm cụ thể, rồi tạo test để bắt các lỗi mà bộ test hiện có bỏ sót. Báo cáo triển khai ghi nhận 9.095 mutant và 571 test; các kỹ sư chấp nhận 73% số test trong các buổi test-a-thon của Messenger và WhatsApp. Điểm hữu ích ở đây là một test mới vẫn có thể nâng khả năng phát hiện lỗi, dù nó chạy trên những dòng code đã được test cũ bao phủ. Bài nghiên cứu của Meta
Agent có thể giúp cải thiện test, nhưng tôi vẫn phải quyết định chương trình nên làm gì. Nếu không, nó chỉ có thể diễn đạt cùng một giả định sai bằng các test kỹ hơn.
Đưa phản hồi về gần thay đổi đang làm
Lo ngại dễ thấy nhất là thời gian chạy. Nếu chạy nguyên bộ test mất năm phút cho từng mutant trong số 1.000 mutant, tổng thời gian sẽ khoảng 83 giờ.
Các công cụ hiện đại tránh lặp lại phần lớn công việc đó. Stryker có thể chọn test dựa trên coverage của từng test, còn incremental mode dùng lại kết quả cũ khi phù hợp. Vì thế, chạy trên một phần logic nhỏ có liên quan khác hẳn với việc mutate cả repository mỗi lần. Cấu hình Stryker, incremental mode
Với Comwit và template dự án của nó, tôi áp dụng vài quy tắc thực tế:
- Bắt đầu từ logic ứng dụng: tiện ích, logic phía server, API và state. Trong thiết lập này, tôi loại page, routing, UI component và schema cơ sở dữ liệu.
- Trong công việc hằng ngày, chạy mutation test trên các file đã thay đổi. Thỉnh thoảng chạy rộng hơn để có số liệu nền.
- Khi agent thay đổi test, yêu cầu nó chạy lệnh mutation dành cho file đã thay đổi và xem các mutant sống sót.
- Phân tích nguyên nhân mutant sống sót. Thay đổi ở ranh giới gợi ý test tại giá trị chính xác; nhánh chưa chạy gợi ý một đường đi đầu vào còn thiếu. Với hành vi tương đương hoặc thay đổi log không liên quan, cần cân nhắc theo cách khác.
Lệnh trong repository của tôi là pnpm test:mutation:changed. Đây là script riêng của dự án, không phải lệnh có sẵn trong Stryker.
Tôi cũng chạy một lượt rộng hơn để lấy số liệu nền: 902 file logic, 41.343 mutant, mất khoảng 49 phút trong môi trường của tôi.
| Khu vực | Tổng mutant | Mutant có coverage | Survived | Điểm trên mutant có coverage |
|---|---|---|---|---|
| Toàn bộ logic trong phạm vi | 41.343 | 10.230 | 3.799 | 62,9% |
src/server/repository |
12.516 | 5.121 | 2.021 | 60,5% |
src/server/domain-jobs |
2.490 | 1.084 | 459 | 57,7% |
Mẫu số rất quan trọng. Con số 62,9% mô tả các mutant có coverage, không phải toàn bộ 41.343 mutant đã tạo. Chỉ khoảng một phần tư tổng số mutant có coverage, và hơn một phần ba số đã có coverage vẫn sống sót.
Những con số đó giúp tôi tìm nơi cần kiểm tra tiếp. Chúng không cho tôi một ước lượng đáng tin cậy về số bug trong ứng dụng thực tế.
Điểm số cho biết nơi cần tìm hiểu thêm
Một mutant sống sót không tự động đồng nghĩa với việc thiếu yêu cầu cần kiểm tra. Một số mutation không làm thay đổi hành vi quan sát được. Test kiểm tra đúng hành vi đó sẽ không thể tiêu diệt các mutant tương đương này.
Mutation testing cũng không chứng minh phần triển khai khớp với đặc tả mong muốn. Nó kiểm tra mức độ nhạy của test với những thay đổi được tạo ra. Bộ test và phần triển khai có thể khớp nhau nhưng cùng thống nhất trên một hành vi sai.
Vì thế, tôi không muốn biến điểm mutation thành KPI của nhóm. Rất dễ tối ưu con số rồi quên lý do mình chạy công cụ.
Tôi vẫn nhờ AI viết test. Điều thay đổi là câu hỏi tiếp theo: nếu đoạn code này sai đi một chút, test nào sẽ nhận ra?
Ở lần thử đầu tiên, một phút chạy mutation test đã cho tôi một danh sách cần review hữu ích hơn nhiều so với thêm một dấu tích xanh để yên tâm.
Tôi xây Comwit, nơi quy trình này là một phần của template dự án. Bài viết chuyển thể từ bản gốc tiếng Hàn của tôi trên Velog, đăng ngày 7 tháng 10 năm 2026.
Liên hệ: X @meursyphus.
All rights reserved