Automated Unit Testing: AI viết test từ requirements
7/19/2026 · 13p đọc
title: "Automated Unit Testing: AI viết test từ requirements"
series: "AI-Native Solutions Architect: Từ Coder đến Kiến trúc sư AI"
season: "Season 1 — AI-Native Coding & Tooling"
order: 7
audience: "Software Engineer hướng tới Solutions Architect"
reading_time: "13 phút"
tags: ["unit testing", "AI-assisted testing", "Jest", "TypeScript", "acceptance criteria", "test coverage", "prompt engineering"]
Automated Unit Testing: AI viết test từ requirements
Bạn còn nhớ lần cuối cùng một sprint có đủ thời gian để viết test cho toàn bộ acceptance criteria không? Thực tế phổ biến hơn nhiều: feature xong, demo chạy được, PR được duyệt với vài test happy-path cho có, còn lại "để sau" — và "sau" đó gần như không bao giờ đến. Đến khi bug xuất hiện ở production, root cause thường là một edge case đã được viết rõ ràng trong ticket từ đầu nhưng không ai test.
Đây không phải vấn đề về ý chí hay kỷ luật của dev. Đó là vấn đề về đánh đổi thời gian: viết test đầy đủ (happy path + boundary + invalid input + edge case) tốn thời gian ngang, thậm chí hơn, viết chính feature đó. Khi deadline ép, phần bị cắt luôn là test — vì nó không hiện diện trực tiếp trên UI cho product owner thấy.
AI code assistant hứa hẹn giải quyết bài toán này: "viết test cho tôi trong vài giây." Nhưng nếu bạn từng thử prompt kiểu "viết unit test cho hàm applyDiscount này", bạn sẽ nhận ra vấn đề mới: AI sinh ra một bộ test trông rất chuyên nghiệp — đủ describe/it, đủ assertion — nhưng chỉ cover đúng những gì code đang làm, chứ không cover những gì code phải làm theo yêu cầu nghiệp vụ. Đó là test ăn theo implementation, không phải test ăn theo specification. Bài này nói về cách thu hẹp khoảng cách đó.
Vấn đề
Khi bạn yêu cầu AI "viết test cho function này" mà chỉ đưa code, AI đang làm một việc duy nhất nó có thể làm: suy ngược từ logic hiện tại ra các nhánh rẽ (branch) rồi sinh test khớp với từng nhánh. Cách tiếp cận này có hai lỗ hổng cấu trúc:
1. Test trở thành gương phản chiếu của bug. Nếu logic gốc có bug — ví dụ điều kiện so sánh dùng > thay vì >= ở ngưỡng áp dụng mã giảm giá — AI đọc code và sinh test khẳng định đúng cái hành vi sai đó. Test pass 100%, coverage report xanh rực, nhưng bug vẫn nằm nguyên trong production. Bạn không kiểm tra "code có đúng yêu cầu không", bạn chỉ đang kiểm tra "code có đúng với chính nó không" — một vòng lặp vô nghĩa về mặt QA.
2. AI không biết những gì không nằm trong code. Acceptance criteria thường có các ràng buộc chỉ tồn tại ở tầng nghiệp vụ mà chưa chắc đã được implement đầy đủ: "không áp dụng đồng thời 2 mã giảm giá", "chỉ áp dụng cho đơn hàng trên ngưỡng X". Nếu dev quên implement một nhánh, AI đọc code sẽ không thấy nhánh đó tồn tại — và do đó cũng không sinh test cho nó. Bạn cần đúng cái test sẽ phát hiện rằng nhánh đó thiếu, nhưng nguồn thông tin duy nhất AI có (code) lại không chứa thông tin ấy.
Nói cách khác: nếu input của AI là code, output tối đa AI có thể đạt được là "test đồng nhất với code", chưa bao giờ là "test đồng nhất với requirement". Đây chính là khoảng trống mà kỹ thuật sinh test có kiểm soát chất lượng phải lấp đầy.
Kỹ thuật cốt lõi
Nguyên tắc cốt lõi: coi requirement/acceptance criteria là nguồn sự thật (source of truth), code chỉ là một trong các input phụ. Khi bạn yêu cầu AI sinh test, hãy đưa cho nó cùng lúc:
- Acceptance criteria gốc (ở dạng Given/When/Then hoặc bullet rõ ràng — tinh thần User Story/BDD).
- Function signature hoặc interface (để AI biết cách gọi, kiểu dữ liệu vào/ra).
- Framework/convention của dự án (Jest, kiểu mock, style assertion đang dùng).
- Yêu cầu tường minh: liệt kê rõ 4 nhóm test case cần có — happy path, boundary, invalid input, và yêu cầu AI tự suy luận thêm ít nhất một edge case chưa được đặc tả rõ (buộc AI phải "đọc kỹ" thay vì chỉ liệt kê máy móc).
Quy trình có thể hình dung như một pipeline có vòng phản hồi, không phải một lệnh sinh-là-xong:
flowchart LR
A[Acceptance Criteria<br/>+ Function Signature] --> B[Prompt có cấu trúc<br/>4 nhóm test case]
B --> C[AI sinh test suite]
C --> D{Dev review<br/>từng test case}
D -- test yếu/sai logic --> B
D -- test đúng ý --> E[Chạy test<br/>đối chiếu coverage]
E -- thiếu nhánh AC --> B
E -- đủ nhánh --> F[Merge vào codebase]
Điểm khác biệt so với "viết test cho hàm này" nằm ở bước D — dev không review test bằng cách nhìn xem nó pass hay fail, mà đọc từng assertion và tự hỏi: "Nếu logic nghiệp vụ ở đây bị code sai, test này có bắt được không?" Đây là câu hỏi kiểm định (test-the-test) mà chỉ con người trả lời được, vì nó đòi hỏi hiểu ý định nghiệp vụ — thứ AI không có quyền truy cập trực tiếp, nó chỉ suy luận từ text bạn cung cấp.
Một hệ quả thực dụng của kỹ thuật này: chất lượng test AI sinh ra tỷ lệ thuận với chất lượng acceptance criteria bạn viết. Nếu AC mơ hồ ("giảm giá hợp lý cho đơn lớn"), test sinh ra cũng mơ hồ theo. Đây là lý do một Solutions Architect nên đầu tư vào việc chuẩn hoá cách viết AC (Given/When/Then, có số liệu cụ thể) không chỉ vì lợi ích cho QA thủ công, mà vì nó trực tiếp nâng chất lượng output khi dùng AI để sinh test.
Thực hành
Bước 1 — Acceptance criteria làm input
Giả sử ticket của bạn có acceptance criteria sau cho tính năng áp mã giảm giá:
Feature: Áp dụng mã giảm giá cho đơn hàng
AC1 (Happy path):
Given đơn hàng có subtotal >= 500.000đ
And mã giảm giá còn hiệu lực, chưa áp dụng mã nào khác
When khách hàng áp mã giảm giá hợp lệ
Then hệ thống áp dụng đúng % giảm giá của mã lên subtotal
AC2 (Ngưỡng tối thiểu):
Given đơn hàng có subtotal < 500.000đ
When khách hàng áp mã giảm giá
Then hệ thống từ chối và trả lỗi "ORDER_BELOW_MIN_THRESHOLD"
AC3 (Không cộng dồn mã):
Given đơn hàng đã áp dụng 1 mã giảm giá hợp lệ
When khách hàng áp thêm một mã giảm giá thứ hai
Then hệ thống từ chối và trả lỗi "DISCOUNT_ALREADY_APPLIED"
AC4 (Mã hết hạn):
Given mã giảm giá có expiresAt đã qua thời điểm hiện tại
When khách hàng áp mã
Then hệ thống từ chối và trả lỗi "DISCOUNT_EXPIRED"
Và function signature đã có sẵn trong codebase:
interface DiscountCode {
code: string;
discountPercent: number; // 0-100
minOrderAmount: number; // VND
expiresAt: Date;
}
interface Order {
subtotal: number; // VND, trước thuế
appliedDiscountCodes: string[];
}
type ApplyResult =
| { ok: true; finalAmount: number }
| { ok: false; errorCode: 'ORDER_BELOW_MIN_THRESHOLD' | 'DISCOUNT_ALREADY_APPLIED' | 'DISCOUNT_EXPIRED' };
function applyDiscountCode(order: Order, code: DiscountCode, now: Date): ApplyResult;
Bước 2 — Prompt mẫu nguyên văn
Đây là prompt thực tế đưa vào AI assistant (Cursor/Copilot Chat/Claude), đưa cả AC lẫn signature, và yêu cầu tường minh 4 nhóm test case:
Bạn là senior engineer viết unit test bằng Jest + TypeScript cho function
`applyDiscountCode` dưới đây. Tôi cung cấp acceptance criteria GỐC của
tính năng — hãy dùng AC này làm nguồn sự thật, KHÔNG suy luận test case
chỉ từ cách code hiện đang implement.
=== ACCEPTANCE CRITERIA ===
AC1 (Happy path): đơn hàng subtotal >= 500.000đ, mã hợp lệ chưa hết hạn,
chưa áp mã nào khác -> áp dụng đúng % giảm giá.
AC2 (Ngưỡng tối thiểu): subtotal < 500.000đ -> từ chối,
lỗi ORDER_BELOW_MIN_THRESHOLD.
AC3 (Không cộng dồn mã): đơn đã áp 1 mã -> áp thêm mã thứ 2 bị từ chối,
lỗi DISCOUNT_ALREADY_APPLIED.
AC4 (Mã hết hạn): expiresAt đã qua -> từ chối, lỗi DISCOUNT_EXPIRED.
=== FUNCTION SIGNATURE ===
[dán interface DiscountCode, Order, ApplyResult, applyDiscountCode ở trên]
=== YÊU CẦU BỘ TEST ===
Sinh test suite Jest đầy đủ, PHẢI bao phủ đúng 4 nhóm sau (đặt tên
describe rõ theo từng nhóm):
1. Happy path — đúng theo AC1.
2. Boundary case — test TẠI ĐÚNG NGƯỠNG 500.000đ (subtotal = 500000,
không lớn hơn không nhỏ hơn) để xác nhận điều kiện dùng >= chứ
không phải >.
3. Invalid case — dữ liệu vi phạm AC2, AC3, AC4 (tách riêng từng case).
4. Ít nhất MỘT edge case bạn tự suy luận thêm mà AC không nói rõ nhưng
có khả năng gây bug thực tế (ví dụ: thứ tự kiểm tra ưu tiên khi đơn
hàng VỪA dưới ngưỡng VỪA có mã hết hạn cùng lúc — hệ thống nên trả
lỗi nào trước? Hoặc: subtotal âm/bằng 0 có hợp lệ không?).
Hãy nêu rõ trong comment tại sao bạn thêm case này.
Không viết test kiểu chỉ gọi function rồi assert `toBeDefined()` —
mỗi test phải assert đúng giá trị/errorCode kỳ vọng theo AC.
Bước 3 — Test suite AI sinh ra (đã review và giữ lại)
Đây là ví dụ rút gọn của kết quả sau khi review — đã bỏ những assertion yếu mà AI sinh lần đầu (ví dụ chỉ check result.ok === false mà không check đúng errorCode) và giữ lại phần đạt yêu cầu:
import { applyDiscountCode, Order, DiscountCode } from './discount';
const NOW = new Date('2026-07-19T00:00:00Z');
function makeCode(overrides: Partial<DiscountCode> = {}): DiscountCode {
return {
code: 'SALE10',
discountPercent: 10,
minOrderAmount: 500_000,
expiresAt: new Date('2026-12-31T00:00:00Z'),
...overrides,
};
}
function makeOrder(overrides: Partial<Order> = {}): Order {
return {
subtotal: 600_000,
appliedDiscountCodes: [],
...overrides,
};
}
describe('applyDiscountCode — happy path (AC1)', () => {
it('áp dụng đúng % giảm giá khi subtotal vượt ngưỡng và mã hợp lệ', () => {
const order = makeOrder({ subtotal: 1_000_000 });
const code = makeCode({ discountPercent: 10 });
const result = applyDiscountCode(order, code, NOW);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.finalAmount).toBe(900_000);
}
});
});
describe('applyDiscountCode — boundary case (đúng ngưỡng tối thiểu)', () => {
it('áp dụng thành công khi subtotal BẰNG CHÍNH XÁC minOrderAmount', () => {
const code = makeCode({ minOrderAmount: 500_000, discountPercent: 20 });
const order = makeOrder({ subtotal: 500_000 });
const result = applyDiscountCode(order, code, NOW);
// Khẳng định điều kiện dùng >=, không phải >
expect(result.ok).toBe(true);
});
it('từ chối khi subtotal thấp hơn ngưỡng đúng 1 đồng', () => {
const code = makeCode({ minOrderAmount: 500_000 });
const order = makeOrder({ subtotal: 499_999 });
const result = applyDiscountCode(order, code, NOW);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errorCode).toBe('ORDER_BELOW_MIN_THRESHOLD');
}
});
});
describe('applyDiscountCode — invalid cases (AC2, AC3, AC4)', () => {
it('từ chối với ORDER_BELOW_MIN_THRESHOLD khi subtotal dưới ngưỡng', () => {
const order = makeOrder({ subtotal: 100_000 });
const result = applyDiscountCode(order, makeCode(), NOW);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errorCode).toBe('ORDER_BELOW_MIN_THRESHOLD');
});
it('từ chối với DISCOUNT_ALREADY_APPLIED khi đơn đã áp 1 mã', () => {
const order = makeOrder({ appliedDiscountCodes: ['SALE05'] });
const result = applyDiscountCode(order, makeCode({ code: 'SALE10' }), NOW);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errorCode).toBe('DISCOUNT_ALREADY_APPLIED');
});
it('từ chối với DISCOUNT_EXPIRED khi mã đã hết hạn', () => {
const code = makeCode({ expiresAt: new Date('2026-01-01T00:00:00Z') });
const result = applyDiscountCode(makeOrder(), code, NOW);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errorCode).toBe('DISCOUNT_EXPIRED');
});
});
describe('applyDiscountCode — edge case suy luận thêm: thứ tự ưu tiên lỗi', () => {
// Edge case AC không nói rõ: nếu đơn VỪA dưới ngưỡng VỪA dùng mã đã
// hết hạn, hệ thống nên trả lỗi nào trước? Đây là hành vi cần chốt
// rõ với BA/PO — test này pin lại quyết định để tránh regression
// âm thầm đổi thứ tự kiểm tra khi refactor.
it('ưu tiên trả ORDER_BELOW_MIN_THRESHOLD trước khi kiểm tra hạn mã', () => {
const order = makeOrder({ subtotal: 100_000 });
const code = makeCode({
expiresAt: new Date('2020-01-01T00:00:00Z'), // đã hết hạn
});
const result = applyDiscountCode(order, code, NOW);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errorCode).toBe('ORDER_BELOW_MIN_THRESHOLD');
}
});
});
Chú ý: test cuối cùng (ưu tiên trả ORDER_BELOW_MIN_THRESHOLD...) là ví dụ điển hình của "edge case suy luận thêm" — AC gốc không nói gì về thứ tự kiểm tra khi nhiều điều kiện lỗi xảy ra đồng thời. Đây chính là loại gap mà AI đọc thuần code không bao giờ tự phát hiện ra để hỏi, nhưng khi được yêu cầu tường minh trong prompt ("hãy suy luận thêm 1 case AC không nói rõ"), nó có thể gợi ý đúng hướng — miễn là dev đọc lại và quyết định hành vi mong muốn là gì (ở đây: chốt cùng BA rằng lỗi ngưỡng tối thiểu ưu tiên hơn lỗi hết hạn, rồi implement đúng thứ tự đó).
Bảng đối chiếu AC ↔ test case (traceability)
Một thói quen tốt khi review: lập bảng map ngược từ AC sang test, để phát hiện AC nào chưa có test tương ứng.
| Acceptance Criteria | Test case tương ứng | Trạng thái |
|---|---|---|
| AC1 — Happy path | áp dụng đúng % giảm giá khi subtotal vượt ngưỡng |
✅ |
| AC2 — Ngưỡng tối thiểu | từ chối với ORDER_BELOW_MIN_THRESHOLD + 2 test boundary |
✅ |
| AC3 — Không cộng dồn mã | từ chối với DISCOUNT_ALREADY_APPLIED |
✅ |
| AC4 — Mã hết hạn | từ chối với DISCOUNT_EXPIRED |
✅ |
| (chưa có trong AC gốc) Thứ tự ưu tiên lỗi | ưu tiên trả ORDER_BELOW_MIN_THRESHOLD... |
⚠️ cần BA xác nhận |
Bảng này — không phải % coverage — mới là thước đo có ý nghĩa nghiệp vụ để báo cáo trong PR.
Cạm bẫy thường gặp
1. Merge test AI sinh mà không tự đọc từng assertion. Test có thể "pass" nhưng assert sai giá trị hoặc assert quá lỏng (expect(result).toBeTruthy() thay vì expect(result.finalAmount).toBe(900_000)). Một test lỏng sẽ tiếp tục pass ngay cả khi logic nghiệp vụ bị đổi sai ở lần refactor sau — nó tạo cảm giác an toàn giả (false sense of safety) nguy hiểm hơn cả việc không có test.
2. Coi số lượng test case là KPI thay vì độ bao phủ ý nghĩa. AI rất giỏi sinh ra 20 test case trông đa dạng nhưng thực chất lặp lại cùng một nhánh logic với input khác nhau không mang thêm thông tin (ví dụ test subtotal = 600.000 và subtotal = 700.000 đều chỉ xác nhận lại happy path). Coverage report sẽ đẹp, nhưng không giảm rủi ro bug thực. Ưu tiên chất lượng theo bảng traceability AC↔test ở trên, không phải đếm số it().
3. Tin rằng AI "tự nhớ" AC cũ khi codebase thay đổi. Nếu requirement được cập nhật (ví dụ ngưỡng đổi từ 500.000đ lên 1.000.000đ) nhưng bạn chỉ yêu cầu AI "update test cho khớp code mới", nó sẽ lặp lại đúng sai lầm ban đầu: khớp với code, không khớp với yêu cầu mới. Luôn quay lại bước 1 — đưa AC mới nhất làm input, không dựa vào lịch sử hội thoại hay code cũ làm nguồn sự thật.
🧭 Góc nhìn Solutions Architect
Nếu một test AI sinh ra pass nhưng bạn không thể giải thích bằng một câu tại sao nó pass đúng theo nghiệp vụ, bạn có nên merge nó không? Traceability AC↔test có nên là một cổng review bắt buộc (không phải optional) trước khi PR được duyệt? Và câu hỏi lớn hơn: liệu team của bạn có đang đo "test coverage" như một con số phù phiếm, trong khi thứ thực sự cần đo là "requirement coverage" — bao nhiêu % acceptance criteria đã có test pin hành vi tương ứng?
🔗 Bài viết liên quan
- Advanced Prompting: Chain-of-Thought và ReAct — kỹ thuật cấu trúc prompt sâu hơn giúp AI suy luận đúng thay vì đoán mò, áp dụng trực tiếp khi viết prompt sinh test.
- Refactoring with AI và Clean Architecture — bộ test bám sát AC chính là lưới an toàn bắt buộc phải có trước khi để AI tham gia refactor.
Bài trước: AI & Legacy Code · Bài tiếp theo: Security & Code Scanning