Dùng AI viết unit test: cách làm đúng để test có giá trị
AI viết test rất nhanh, nhưng nhiều bộ test xanh mà không bắt được bug nào. Workflow TDD với AI (spec trước, test fail trước, cấm sửa test), checklist review và mutation testing, kèm ví dụ Vitest chạy được.
Viết test là một trong những việc dễ giao cho AI nhất. Gõ "viết test cho file này", vài giây sau có 15 test case xanh, coverage tăng vọt.
Nhưng test xanh chưa chắc có giá trị. Một bộ test chỉ có ích khi nó fail lúc code sai, và nhiều bộ test AI viết không làm được điều đó: chúng chạy qua code nhưng không thực sự kiểm tra gì.
Nếu bạn đang dùng Claude Code, Cursor hoặc Copilot với JS/TS, dưới đây là các lỗi hay gặp khi dùng AI viết unit test, một workflow để test bắt được bug, checklist review và mutation testing. Ví dụ Vitest trong bài đã được chạy thử trên Vitest 5.0.3.
Vì sao test AI viết hay "đẹp mà rỗng"
Nếu bạn không đưa spec, AI sẽ suy ra "hành vi đúng" từ thứ nó thấy rõ nhất: code hiện tại. Kết quả là vài kiểu lỗi lặp đi lặp lại:
- Assertion vô nghĩa.
expect(result).toBeDefined(),expect(typeof x).toBe('string'). Test pass với gần như mọi output, kể cả output sai. - Soi gương code (mirroring). Expected value được tính bằng đúng logic của hàm đang test. Code sai thì test "sai theo" và vẫn xanh. Nếu code đang có bug, AI có thể viết test khẳng định luôn bug đó là đúng.
- Test implementation detail. Kiểm tra hàm nội bộ được gọi mấy lần, state bên trong component, thay vì output hoặc thứ người dùng thấy. Refactor một chút là test vỡ dù hành vi không đổi.
- Mock quá tay. Mock luôn phần logic cần kiểm tra, rồi assert rằng mock trả về đúng giá trị đã cấu hình. Test chỉ đang kiểm tra chính nó.
- Lạm dụng snapshot. Snapshot đầu tiên ghi lại output hiện tại, đúng hay sai đều được ghi. Lần sau fail, người ta (hoặc agent) chạy
vitest -ucho xong. - Sửa test cho pass. Được bảo "làm cho test pass", agent có thể chọn đường ngắn nhất: xóa assertion, thêm
.skip, đổi expected value.
Tài liệu VS Code về testing với Copilot cũng cảnh báo đúng những điểm này: đừng chấp nhận assertion bị xóa, test bị skip hay expected value bị đổi chỉ để có kết quả pass.
Ví dụ: một hàm nhỏ, hai bộ test
Lấy một hàm quen thuộc: chuẩn hóa số điện thoại di động Việt Nam. Spec của app ví dụ (spec giả định, không phải quy định chính thức về đầu số):
- Chấp nhận dạng
0xxxxxxxxx,+84xxxxxxxxx,84xxxxxxxxx, cho phép dấu cách, dấu chấm, gạch ngang. - Sau khi bỏ
+84,84hoặc số0ở đầu, phải còn đúng 9 chữ số, chữ số đầu là 3, 5, 7, 8 hoặc 9. - Trả về dạng
0xxxxxxxxx, input không hợp lệ trả vềnull. - Dạng
+84 0912...(vừa +84 vừa số 0) coi là không hợp lệ.
Dòng cuối là một quyết định nghiệp vụ. AI không tự biết bạn muốn chấp nhận hay từ chối trường hợp này, đó là phần việc của bạn.
// src/phone.ts
// Đầu số di động hợp lệ (chữ số ngay sau số 0) theo spec của app ví dụ
const MOBILE_PREFIXES = ['3', '5', '7', '8', '9'];
export function normalizeVnPhone(input: string): string | null {
const compact = input.trim().replace(/[\s.-]/g, '');
let local: string;
if (compact.startsWith('+84')) {
local = compact.slice(3);
} else if (compact.startsWith('84') && compact.length === 11) {
local = compact.slice(2);
} else if (compact.startsWith('0')) {
local = compact.slice(1);
} else {
return null;
}
if (!/^\d{9}$/.test(local)) return null;
if (!MOBILE_PREFIXES.includes(local.charAt(0))) return null;
return '0' + local;
}Bộ test "đẹp mà rỗng"
Đây là kiểu test bạn dễ nhận được khi chỉ gõ "viết test cho phone.ts":
// src/phone.weak.test.ts
import { describe, it, expect } from 'vitest';
import { normalizeVnPhone } from './phone';
describe('normalizeVnPhone', () => {
it('should work', () => {
const result = normalizeVnPhone('0912345678');
expect(result).toBeDefined();
});
it('should handle +84', () => {
const input = '+84912345678';
expect(normalizeVnPhone(input)).toBe('0' + input.slice(3));
});
it('should return a string', () => {
expect(typeof normalizeVnPhone('0912 345 678')).toBe('string');
});
});Ba test, đều xanh. Nhưng toBeDefined() vẫn pass khi hàm trả về null. Test thứ hai tính expected bằng đúng logic của hàm. Và không có một case không hợp lệ nào.
Mình thử xóa hẳn dòng kiểm tra đầu số (MOBILE_PREFIXES), tức là cho phép cả số 0212345678 lọt qua. Bộ test này vẫn pass 3/3.
Bộ test có giá trị
// src/phone.test.ts
import { describe, it, expect } from 'vitest';
import { normalizeVnPhone } from './phone';
describe('normalizeVnPhone', () => {
describe('input hợp lệ', () => {
it.each([
['0912345678', '0912345678'],
['0912 345 678', '0912345678'],
['0912.345.678', '0912345678'],
['0912-345-678', '0912345678'],
['+84912345678', '0912345678'],
['+84 912 345 678', '0912345678'],
['84912345678', '0912345678'],
[' 0381234567 ', '0381234567'],
['0512345678', '0512345678'],
['0712345678', '0712345678'],
['0812345678', '0812345678'],
])('"%s" -> "%s"', (input, expected) => {
expect(normalizeVnPhone(input)).toBe(expected);
});
});
describe('input không hợp lệ trả về null', () => {
it.each([
['', 'chuỗi rỗng'],
['091234567', 'thiếu 1 chữ số'],
['09123456789', 'thừa 1 chữ số'],
['912345678', 'thiếu số 0 ở đầu'],
['0212345678', 'đầu số 02 không phải di động'],
['0612345678', 'đầu số 06 không có trong spec'],
['+840912345678', 'vừa +84 vừa số 0'],
['8491234567', 'dạng 84 nhưng thiếu số'],
['09123a5678', 'có chữ cái'],
['0912_345_678', 'ký tự phân cách lạ'],
])('"%s" (%s)', (input) => {
expect(normalizeVnPhone(input)).toBeNull();
});
});
});Khác biệt nằm ở ba điểm: expected value viết cứng, không tính lại bằng code; mỗi dòng trong spec có ít nhất một case tương ứng; có cả nhóm input sai. Với cùng bug xóa dòng kiểm tra đầu số ở trên, bộ này fail 2 test (0212... và 0612...), đúng như mong đợi.
Chạy bằng npx vitest run (một lần) hoặc npx vitest (watch mode). Vitest 5 cần Node 22.12+ hoặc Node 24+ (các bản LTS).
Workflow dùng AI viết unit test để test có giá trị
Bước 1: Viết spec trước, không phải code
Trước khi nhờ AI, viết các hành vi thành gạch đầu dòng như spec ở trên, kèm vài cặp input/output cụ thể. Docs Claude Code gọi đây là "provide verification criteria".
Bước 2: Yêu cầu test plan trước khi viết code
Hướng dẫn của VS Code gợi ý một trình tự hợp lý: bảo agent đọc cấu hình test của dự án, sau đó đề xuất test case mà chưa sửa file nào, bạn duyệt xong mới cho viết. Prompt mẫu:
Đọc src/phone.ts và spec dưới đây. CHƯA viết code.
Liệt kê danh sách test case cho normalizeVnPhone dạng bảng: input, expected, lý do.
Tách nhóm hợp lệ và không hợp lệ. Chủ động tìm edge case mình chưa nghĩ tới
(khoảng trắng, ký tự lạ, độ dài biên, mã quốc gia viết sai).
Nếu spec có chỗ mơ hồ, hỏi mình thay vì tự quyết.
Spec:
- ...Đọc một bảng test case nhanh hơn nhiều so với đọc cả file test. Case nào thiếu, case nào AI hiểu sai spec, bạn sửa ngay ở đây.
Bước 3: TDD với AI, và không cho agent sửa test
Với code mới, test driven development với AI hoạt động rất tốt vì agent có một mục tiêu rõ ràng để lặp. Blog best practices của Cursor mô tả quy trình gồm các bước:
- Nhờ agent viết test từ các cặp input/output, nói rõ bạn đang làm TDD để nó không tạo mock implementation cho phần chưa tồn tại.
- Bảo agent chạy test và xác nhận test fail. Nói rõ chưa được viết implementation ở bước này.
- Commit bộ test khi bạn đã hài lòng.
- Nhờ agent viết code để test pass, dặn không được sửa test, cho nó lặp tới khi tất cả test pass.
- Commit phần implementation khi bạn đã kiểm tra xong.
Bước 2 hay bị bỏ qua nhưng rất quan trọng: một test chưa từng fail thì bạn chưa biết nó có khả năng bắt lỗi hay không. Bước 3 cho bạn một điểm mốc: nếu agent lén sửa test, git diff trên file test sẽ lộ ra ngay.
Prompt mẫu cho bước 4:
Test trong src/phone.test.ts đã được commit. Viết normalizeVnPhone trong src/phone.ts
để toàn bộ test pass.
KHÔNG sửa, xóa hay skip bất kỳ test nào. Nếu bạn nghĩ một test sai, dừng lại
và giải thích, đừng tự sửa.
Chạy `npx vitest run src/phone.test.ts` sau mỗi lần sửa, dán output cuối cùng.Docs Claude Code còn gợi ý một biến thể mạnh hơn: dùng hai phiên riêng, một phiên viết test, một phiên khác viết code cho test pass. Phiên viết code không mang theo suy nghĩ của phiên viết test, nên khó "thông đồng" hơn.
Với code có sẵn (legacy) thì không TDD được theo đúng nghĩa. Lúc đó hãy nói rõ trong prompt: test phải theo spec, nếu hành vi hiện tại khác spec thì báo lại, đừng viết test khẳng định hành vi hiện tại là đúng.
Bước 4: Cho agent một vòng kiểm chứng
Docs Claude Code nhấn mạnh việc cho agent một thứ trả về pass/fail để nó tự chạy, đọc kết quả và sửa tới khi qua. Với test, vòng này có sẵn: npx vitest run hoặc npx jest. Vài cách siết chặt hơn:
- Ghi lệnh test vào file hướng dẫn dự án để agent không phải đoán. Ví dụ trong
CLAUDE.mdhoặcAGENTS.md:
# Testing
- Test runner: Vitest. Chạy một file: `npx vitest run path/to/file.test.ts`
- Không sửa, xóa hay skip test có sẵn chỉ để cho pass. Nghĩ test sai thì dừng lại và hỏi.
- Expected value viết cứng trong test, không tính lại bằng chính hàm đang test.
- Ưu tiên test hành vi qua public API, hạn chế mock.- Đòi bằng chứng, không đòi lời khẳng định. Yêu cầu agent dán lệnh đã chạy và output (số pass, fail, skip). Xem output thật, gồm cả test bị skip, thay vì chỉ đọc tóm tắt của agent.
- Claude Code
/goal: đặt điều kiện hoàn thành, ví dụ/goal npx vitest run src/phone.test.ts exit 0 và git diff --stat src/phone.test.ts không có thay đổi, hoặc dừng sau 15 lượt. Sau mỗi lượt, một model nhỏ riêng đọc hội thoại và đánh giá điều kiện đã đạt chưa. Model này không tự chạy lệnh, nên điều kiện phải là thứ Claude chứng minh được bằng output (kết quả test,git diff). - Stop hook (Claude Code) hoặc stop hook trong
.cursor/hooks.json(Cursor): chạy lệnh test bằng script và không cho agent kết thúc lượt tới khi pass. Cách cấu hình hook có trong bài Claude Code nâng cao.
Bước 5: Review bằng một góc nhìn mới
Model vừa viết code thường dễ dãi với chính code đó. Docs Claude Code gợi ý cho một subagent với context sạch review diff. Cách nhanh nhất là chạy skill có sẵn /code-review. Muốn review riêng phần test theo tiêu chí của bạn thì tạo subagent trong .claude/agents/test-reviewer.md:
---
name: test-reviewer
description: Review test vừa viết hoặc vừa sửa. Tìm assertion yếu, expected value tính lại từ code, mock thay thế logic cần test, test bị skip hoặc bị nới lỏng. Dùng chủ động sau khi viết test.
tools: Read, Grep, Glob, Bash
model: inherit
---
Bạn review test, không sửa code. Với mỗi file test trong diff:
1. Đối chiếu từng hành vi trong spec với test tương ứng, liệt kê hành vi chưa có test.
2. Đánh dấu assertion không thể fail (toBeDefined, typeof, truthy chung chung).
3. Đánh dấu expected value được tính bằng logic giống code đang test.
4. Đánh dấu mock thay thế chính phần logic cần kiểm tra.
5. Chạy `git diff` trên file test đã commit, báo mọi assertion bị xóa hoặc đổi.
Chỉ báo vấn đề ảnh hưởng tới khả năng bắt bug, bỏ qua góp ý style.Checklist review test do AI viết
| Câu hỏi | Dấu hiệu đáng ngờ |
|---|---|
| Test này có thể fail không? | toBeDefined(), toBeTruthy(), typeof cho mọi output |
| Expected value từ đâu ra? | Được tính bằng logic giống hệt code, hoặc gọi chính hàm đang test |
| Mỗi dòng spec có test chưa? | Chỉ có happy path, không có input sai |
| Mock có thay thế thứ cần test không? | Mock module chứa logic chính, assert giá trị mock trả về |
| Test có bám implementation không? | Kiểm tra hàm nội bộ được gọi, state nội bộ, tên class CSS |
| Có snapshot không ai đọc không? | Snapshot lớn, cập nhật bằng -u mà không review |
| So với lần commit trước, test có bị nới lỏng? | Assertion bị xóa, .skip, .todo, expected value bị đổi |
| Test đã từng fail chưa? | Chưa ai thấy nó đỏ lần nào |
Về snapshot: docs Vitest nói rõ file snapshot nên được commit cùng code và review như một phần của code review. Nếu output ngắn, toMatchInlineSnapshot() dễ review hơn vì giá trị nằm ngay trong file test.
Mutation testing: kiểm tra lại chính bộ test
Coverage chỉ cho biết dòng code nào được chạy qua, không cho biết test có kiểm tra gì không. Mutation testing trả lời câu hỏi khác: nếu cố tình cài bug vào code, test có phát hiện không? Công cụ tạo ra các "mutant" (đổi >= thành >, đổi điều kiện thành true, xóa một câu lệnh...) rồi chạy test với từng mutant. Test fail thì mutant bị "killed". Test vẫn pass thì mutant "survived": có một kiểu bug mà test không bắt được. Tỉ lệ killed càng cao, bộ test càng hiệu quả.
Với JS/TS, công cụ phổ biến là StrykerJS:
npm init stryker@latest # hỏi vài câu và tạo stryker.config.mjs
npx stryker runNếu dùng Vitest, chọn runner Vitest khi init, hoặc cài @stryker-mutator/vitest-runner và đặt testRunner: 'vitest' trong config.
Lưu ý về Vitest 5 (tháng 10/2026): @stryker-mutator/vitest-runner 10.0.0 chưa chạy đúng với Vitest 5. Bộ lọc test theo tên không khớp test nào, nên gần như mọi mutant bị báo survived và score tụt về gần 0, dù test của bạn vẫn tốt (issue #6210). Trong lúc chờ bản sửa, chạy Stryker với Vitest 4.1 (số liệu bên dưới mình đo trên StrykerJS 10.0 + Vitest 4.1). Thấy score thấp bất thường thì kiểm tra phiên bản trước khi kết luận test yếu.
Mình chạy thử trên ví dụ số điện thoại (StrykerJS 10.0, Vitest 4.1): bộ test yếu đạt mutation score khoảng 55%, bộ test tốt khoảng 84%. Phần đáng giá là danh sách mutant còn sống của bộ test tốt, nó chỉ ra hai thiếu sót mình không nhận ra:
- Đổi
startsWith('84') && length === 11thành||mà test vẫn pass. Chuỗi03912345678(11 ký tự, bắt đầu bằng 0) lẽ ra bị từ chối, nhưng với code bị đổi lại thành0912345678. Chưa có test cho case này. - Đổi
startsWith('0')thànhtruemà test vẫn pass, vì chưa có case 10 chữ số không bắt đầu bằng 0, như1912345678.
Thêm hai case đó, score lên khoảng 93%. Ba mutant còn lại không phải thiếu test: một cái cho thấy .trim() là thừa (regex \s đã xóa khoảng trắng), hai cái là mutant tương đương, đổi code nhưng không đổi hành vi. Vì vậy đừng ép score lên 100%, hãy đọc từng mutant sống rồi quyết định.
Vì phải chạy lại test cho từng mutant, mutation testing chậm, nên hợp chạy định kỳ cho module quan trọng (tính tiền, phân quyền, validate). Với AI: đưa danh sách mutant sống cho agent và yêu cầu "viết test để kill các mutant này, không sửa src/".
Mẹo theo từng công cụ
GitHub Copilot (VS Code). Có slash command /tests trong chat và smart action Generate Tests (chọn code, rồi Generate Code > Generate Tests). Khi test fail trong Test Explorer, có nút Fix Test Failure. Docs GitHub khuyên mở sẵn vài file test ở tab bên cạnh để Copilot theo đúng phong cách test của dự án. Quy ước test ghi vào .github/copilot-instructions.md hoặc .github/instructions/*.instructions.md với applyTo: '**/*.test.ts'. Xem thêm ở bài GitHub Copilot là gì.
Cursor. Quy trình TDD ở Bước 3 chính là quy trình Cursor khuyến nghị. Dùng Plan Mode khi cần test plan cho cả module, rules trong .cursor/rules/ cho quy ước test (xem bài Cursor rules).
Claude Code. Ghi lệnh test và quy tắc "không sửa test" vào CLAUDE.md, dùng /goal hoặc Stop hook cho vòng kiểm chứng, subagent test-reviewer để review. Dù bạn dùng Opus 5.5 hay Sonnet 5.5, ví dụ trên cho thấy chất lượng test phụ thuộc vào spec bạn đưa vào nhiều hơn là model nào viết.
Tóm lại
AI viết unit test nhanh, nhưng giá trị của test đến từ những thứ AI không tự có: spec đúng, quyết định nghiệp vụ, và một người kiểm tra xem test có thực sự fail khi code sai. Workflow gọn nhất: spec, test plan, test fail trước, cấm sửa test, vòng kiểm chứng, checklist review, và mutation testing cho phần quan trọng.
Để viết được spec tốt và nhìn ra edge case, bạn cần hiểu sâu thứ mình đang build. Nếu bạn làm frontend, khóa React PRO của HoleTex giúp bạn nắm React đủ chắc để biết component nên được test ở đâu và thế nào. Còn tư duy tìm biên và case đặc biệt thì luyện rất tốt qua bài tập thuật toán.
Bài liên quan
- Claude Code nâng cao: CLAUDE.md, subagents, skills, hooks và MCP
- AI workflow lập trình: một ngày code với AI trông thế nào
- Prompt cho lập trình: viết yêu cầu để AI làm đúng
- Cursor vs Copilot vs Claude Code: nên chọn công cụ nào
- TypeScript là gì
Nguồn tham khảo: Best practices for Claude Code (code.claude.com), Common workflows (code.claude.com), /goal (code.claude.com), Best practices for coding with agents (cursor.com), Using Copilot to write tests (docs.github.com), Testing with Copilot (code.visualstudio.com), Copilot features cheat sheet (code.visualstudio.com), Custom instructions (code.visualstudio.com), Vitest Getting Started (vitest.dev), Vitest Snapshot (vitest.dev), Vitest Migration (vitest.dev), StrykerJS Getting started (stryker-mutator.io), Stryker Vitest Runner (stryker-mutator.io), What is mutation testing (stryker-mutator.io), Issue #6210 (github.com/stryker-mutator). Cập nhật 2026-10-08.