Test suite của bạn có thể xanh 100%, coverage 95%, và vẫn để lọt một lỗi làm sai lệch hàng nghìn hoá đơn — property-based testing và mutation testing chính là hai công cụ vạch trần sự thật khó chịu đó.
Giới hạn của example-based test
Phần lớn unit test chúng ta viết hàng ngày là example-based test: bạn nghĩ ra một vài input cụ thể, gọi hàm, rồi assert kết quả mong đợi. Cách này dễ đọc, dễ viết, và bắt được phần lớn lỗi hiển nhiên. Vấn đề là nó chỉ chứng minh được đúng đắn tại đúng những điểm bạn đã nghĩ tới — còn cả một không gian input vô hạn ở giữa và xung quanh những điểm đó thì hoàn toàn bỏ ngỏ.
Lấy ví dụ bài toán proration (tính lại phí khi user upgrade/downgrade gói subscription giữa chu kỳ) mà chúng ta sẽ dùng xuyên suốt bài này. Một bộ test example-based điển hình có thể trông như sau:
test('upgrade giữa chu kỳ 30 ngày, dùng 10 ngày gói cũ', () => {
const result = calculateProration({
oldPlanPrice: 10_00,
newPlanPrice: 20_00,
cycleDays: 30,
daysUsed: 10,
});
expect(result.credit).toBe(6_67); // 20/30 ngày còn lại * 10
expect(result.charge).toBe(13_33);
});
Test này pass, review pass, merge. Nhưng thử hỏi: điều gì xảy ra khi daysUsed = 0 (user upgrade ngay ngày đầu chu kỳ)? Khi daysUsed = cycleDays (upgrade đúng ngày cuối, trước khi renew)? Khi chu kỳ rơi vào tháng 2 năm nhuận, cycleDays = 29? Khi user downgrade từ gói đắt sang gói rẻ hơn — credit có thể lớn hơn charge, dẫn đến số tiền "phải hoàn" âm nếu code không xử lý dấu đúng cách? Một bộ suite chỉ toàn các ca "đẹp" như trên hoàn toàn im lặng trước những boundary này.
Điều đáng lo hơn: khi bạn nhờ AI agent sinh test cho một hàm, nó có xu hướng lặp lại đúng cái bẫy này ở quy mô lớn hơn. Mô hình được huấn luyện để tạo ra ví dụ "hợp lý, dễ hiểu, đại diện" — tức là input tròn trịa, giữa khoảng giá trị, không âm, không bằng 0, không ở biên. Bạn có thể có 20 test case do AI viết, coverage report báo 92%, nhưng tất cả đều xoay quanh happy path vì AI và bạn cùng chia sẻ một trực giác về "input điển hình". Đó chính là lỗ hổng mà property-based testing được sinh ra để lấp.
Mẹo: Khi review test do AI sinh, đừng hỏi "test này có pass không" mà hỏi "input nào KHÔNG có mặt trong suite này" — liệt kê boundary (0, âm, max, rỗng, trùng lặp) trước khi merge.
Xác định property từ business invariant
Property-based testing không thay thế example-based test, nó bổ sung một lớp khác: thay vì khẳng định "với input X, output phải là Y", bạn khẳng định một invariant (bất biến — tính chất luôn đúng bất kể input cụ thể là gì) và để framework tự sinh hàng trăm, hàng nghìn input ngẫu nhiên để tìm phản ví dụ.
Việc khó nhất không phải là học API của fast-check hay Hypothesis — mà là "dịch" một quy tắc nghiệp vụ thành một property có thể kiểm chứng bằng code. Dưới đây là quy trình mình hay dùng khi làm việc với domain billing:
- Viết invariant bằng ngôn ngữ nghiệp vụ. Ví dụ: "Tổng số tiền charge cho gói mới cộng với credit hoàn lại từ gói cũ, sau khi quy đổi, không được vượt quá hoặc thiếu hụt so với giá trị gốc của các ngày còn lại — tiền không được tự sinh ra hay biến mất."
- Rút gọn thành công thức toán học. Với
daysRemaining = cycleDays - daysUsed:charge - creditphải bằng đúng chênh lệch giá trị theo ngày giữa hai gói nhân vớidaysRemaining, sai số cho phép tối đa 1 cent do làm tròn. - Viết thành property statement bằng code, không phải bằng input cụ thể mà bằng biến định lượng (quantified variable):
import fc from 'fast-check';
test('property: không tạo ra hay làm mất tiền khi proration', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 3650 }), // cycleDays
fc.integer({ min: 0, max: 3650 }), // daysUsed
fc.integer({ min: 0, max: 1_000_00 }), // oldPlanPrice (cents)
fc.integer({ min: 0, max: 1_000_00 }), // newPlanPrice (cents)
(cycleDays, daysUsedRaw, oldPlanPrice, newPlanPrice) => {
fc.pre(daysUsedRaw <= cycleDays); // loại input vô nghĩa
const daysUsed = daysUsedRaw;
const result = calculateProration({ oldPlanPrice, newPlanPrice, cycleDays, daysUsed });
const daysRemaining = cycleDays - daysUsed;
const expectedDelta = Math.round(((newPlanPrice - oldPlanPrice) * daysRemaining) / cycleDays);
expect(Math.abs(result.charge - result.credit - expectedDelta)).toBeLessThanOrEqual(1);
}
),
{ numRuns: 2000 }
);
});
Một vài property "kinh điển" đáng nhớ cho domain billing/discount, bạn có thể tái sử dụng gần như nguyên mẫu:
- Non-negativity (không âm): giá cuối cùng sau khi áp discount không bao giờ nhỏ hơn 0, dù discount là 150%.
- Round-trip (khứ hồi): áp coupon phần trăm rồi "undo" nó (chia lại) phải trả về giá gốc trong sai số làm tròn — nếu không, logic áp/undo có nơi bị lệch.
- Idempotency (tính lũy đẳng): gọi hàm
recalculateInvoicehai lần liên tiếp trên cùng state phải cho kết quả giống hệt nhau — dấu hiệu code không có side-effect ẩn hoặc cộng dồn nhầm. - Monotonicity (tính đơn điệu): nếu
daysUsedtăng thìcreditphải giảm (hoặc giữ nguyên), không bao giờ tăng ngược.
Property-based testing đòi hỏi tư duy khác với unit test truyền thống: bạn không còn nghĩ "input này thì output kia", mà nghĩ "quan hệ nào giữa input và output luôn đúng, bất kể giá trị cụ thể". Đây cũng là kỹ năng transfer tốt sang việc review code do AI viết, vì invariant chính là thứ AI dễ vi phạm nhất khi nó "đoán" logic thay vì suy luận chính xác.
Mẹo: Khi brainstorm property với AI agent, đừng hỏi "viết property test cho hàm này" ngay — hỏi trước "liệt kê 5 invariant nghiệp vụ mà hàm này phải giữ đúng", review invariant bằng tay rồi mới yêu cầu sinh code property từ danh sách đã duyệt.
Công cụ: fast-check, Hypothesis, và PropTest
Ba công cụ phổ biến nhất hiện nay, mỗi cái gắn với một hệ sinh thái:
fast-check (JS/TS) — thư viện property-based testing phổ biến nhất cho Node.js, tích hợp trực tiếp với Jest/Vitest qua fc.assert(fc.property(...)). Khái niệm cốt lõi là arbitrary (bộ sinh giá trị ngẫu nhiên có kiểm soát phạm vi) — fc.integer(), fc.string(), fc.record(), fc.array(), và bạn có thể compose chúng để sinh object phức tạp như Invoice hay Subscription. Khi một test fail, fast-check không chỉ báo input gây lỗi mà còn thực hiện shrinking (thu nhỏ) — tự động rút gọn input về phiên bản nhỏ nhất, đơn giản nhất vẫn còn gây fail. Ví dụ nếu input gây lỗi là mảng 47 phần tử ngẫu nhiên, fast-check có thể shrink về mảng 2 phần tử, giúp bạn debug nhanh gấp nhiều lần.
Hypothesis (Python) — tiền bối lâu đời hơn, ảnh hưởng thiết kế của rất nhiều thư viện sau này kể cả fast-check. Dùng decorator @given cùng chiến lược (strategies, viết tắt st):
from hypothesis import given, strategies as st
@given(
cycle_days=st.integers(min_value=1, max_value=3650),
days_used=st.integers(min_value=0, max_value=3650),
old_price=st.integers(min_value=0, max_value=100_000),
new_price=st.integers(min_value=0, max_value=100_000),
)
def test_proration_conserves_money(cycle_days, days_used, old_price, new_price):
assume(days_used <= cycle_days) # loại input không hợp lệ
result = calculate_proration(old_price, new_price, cycle_days, days_used)
days_remaining = cycle_days - days_used
expected_delta = round((new_price - old_price) * days_remaining / cycle_days)
assert abs(result.charge - result.credit - expected_delta) <= 1
Hypothesis cũng có shrinking mạnh, thêm database cache lưu lại các "counterexample" đã tìm được để lần chạy sau ưu tiên thử lại chúng trước — rất hữu ích trong CI vì không phải may rủi lại từ đầu mỗi lần.
proptest (Rust) — tương tự về triết lý (arbitrary + shrinking), dùng macro proptest! { ... }, tích hợp chặt với hệ type của Rust nên strategy cho struct thường được derive tự động. Nếu team bạn có service viết Rust (ví dụ payment engine hiệu năng cao), đây là lựa chọn mặc định.
Vài lưu ý thực chiến khi vận hành các công cụ này:
- Số phép thử (
numRuns): mặc định thường 100, quá ít để tìm boundary hiếm. Với hàm tài chính, nên tăng lên 1000–5000 ở CI, nhưng giảm xuống ở local dev để vòng lặp nhanh. - Floating point là kẻ thù: property so sánh bằng
===trên số thực gần như luôn flaky. Luôn làm việc trên số nguyên (cents) hoặc dùng epsilon tolerance khi bắt buộc dùng float. fc.pre()/assume()dùng để lọc input vô nghĩa (ví dụdaysUsed > cycleDays) nhưng lạm dụng quá nhiều pre-condition sẽ làm framework phải discard hàng loạt input, chạy chậm — đôi khi tốt hơn là generate input đã hợp lệ ngay từ đầu bằng cách ràng buộc range.
Mẹo: Set seed cố định (
fc.configureGlobal({ seed: ... })hoặc biến môi trườngHYPOTHESIS_SEED) khi debug một lần fail trong CI để tái tạo chính xác input gây lỗi, tránh vừa sửa vừa đoán vì random mỗi lần chạy một khác.
Mutation testing để đánh giá độ mạnh của test suite
Coverage 100% dòng lệnh là một ảo tưởng an toàn kinh điển. Code coverage chỉ đo "dòng này có được chạy qua không", không đo "nếu dòng này sai thì test có phát hiện ra không". Đây chính xác là câu hỏi mutation testing trả lời.
Cơ chế hoạt động: công cụ mutation testing tạo ra hàng loạt bản sao code, mỗi bản bị chỉnh sửa một chút — gọi là mutant — rồi chạy lại toàn bộ test suite trên từng bản. Các kiểu chỉnh sửa (mutation operator) phổ biến:
- Đổi toán tử so sánh:
>thành>=,<thành<=,==thành!=. - Lật boolean:
truethànhfalse, đảo điều kiệnif. - Đổi hằng số:
+1thành-1,0thành1. - Xoá một dòng gọi hàm (statement deletion), đổi
&&thành||.
Nếu sau khi mutate, test suite vẫn pass — nghĩa là không có test nào phát hiện sự thay đổi hành vi — mutant đó được gọi là survived (sống sót), và đây là tín hiệu cực mạnh rằng test suite của bạn có lỗ hổng thực sự tại vị trí đó. Nếu test suite fail (đúng như kỳ vọng), mutant bị killed (tiêu diệt). Tỷ lệ mutant bị kill trên tổng số mutant gọi là mutation score — chỉ số phản ánh độ mạnh của test suite chính xác hơn nhiều so với coverage phần trăm dòng.
Ví dụ kinh điển trong proration: hàm có dòng if (daysUsed >= cycleDays) return zeroProration();. Nếu bạn chỉ có test cho daysUsed < cycleDays và daysUsed = cycleDays, một mutant đổi >= thành > có thể vẫn sống sót nếu test không cover đúng boundary daysUsed === cycleDays. Coverage report vẫn báo 100% vì dòng đó đã được "chạm" — nhưng nó chưa từng bị bắt lỗi thật.
Công cụ phổ biến theo ngôn ngữ: Stryker Mutator (JS/TS, hỗ trợ cả Vue/Angular/React), PIT (PITest) (Java, chạy rất nhanh nhờ bytecode manipulation, tích hợp Maven/Gradle), mutmut (Python, đơn giản, chạy tuần tự). Mọi công cụ đều output báo cáo liệt kê từng surviving mutant kèm vị trí dòng, loại mutation, và diff.
Chi phí thật của mutation testing không nhỏ: chạy toàn bộ test suite lại N lần (N = số mutant) khiến thời gian chạy tăng tuyến tính hoặc hơn — một suite unit test mất 2 phút có thể kéo mutation testing lên 2–6 tiếng nếu chạy full codebase. Cách xử lý thực tế:
- Giới hạn phạm vi (
mutateconfig trong Stryker) chỉ vào file/module vừa thay đổi trong PR, không chạy toàn repo mỗi lần. - Chạy mutation testing như một job riêng, không chặn CI pipeline chính — chạy nightly hoặc chạy thủ công trước khi merge module tài chính quan trọng.
- Ưu tiên áp dụng cho các module "critical" (billing, auth, payment) thay vì rải đều toàn bộ codebase — ROI không đồng đều.
- Nhận diện equivalent mutant (mutant thay đổi code nhưng không thay đổi hành vi quan sát được, ví dụ đổi
i++thành++itrong ngữ cảnh không dùng giá trị trả về) — mutant này không thể bị kill dù test có tốt đến đâu, và cần được đánh dấu loại trừ thủ công để không làm sai lệch mutation score.
Mẹo: Chạy Stryker lần đầu với
--incrementalvà giới hạnmutatevào diff của PR (ví dụ quagit diff --name-only origin/main) — full-repo mutation run trên codebase lớn dễ khiến cả team bỏ cuộc vì chờ quá lâu.
Dùng AI để đọc kết quả mutation testing và cải thiện test
Báo cáo mutation testing thường là một danh sách dài các surviving mutant kèm số dòng và diff — đọc thủ công hàng trăm mutant rất tốn thời gian. Đây là chỗ AI agent phát huy tốt: đưa báo cáo (hoặc từng surviving mutant) cho agent, yêu cầu nó phân tích tại sao mutant sống sót và đề xuất test case cụ thể để diệt nó.
Ví dụ một prompt thực chiến, dùng khi Stryker báo mutant sống sót trong hàm calculateProration:
Đây là kết quả mutation testing từ Stryker cho file proration.ts:
Surviving mutant #14:
- File: src/billing/proration.ts, dòng 27
- Original: if (daysUsed >= cycleDays) return zeroProration(oldPlanPrice);
- Mutated: if (daysUsed > cycleDays) return zeroProration(oldPlanPrice);
- Status: SURVIVED (toàn bộ test suite vẫn pass với mutant này)
Đây là toàn bộ test file hiện tại: [dán nội dung proration.test.ts]
Yêu cầu:
1. Giải thích chính xác tại sao mutant này sống sót — test suite hiện tại thiếu case nào ở boundary daysUsed === cycleDays.
2. Đề xuất 1-2 test case mới (unit test, dùng ví dụ cụ thể với số liệu) để diệt mutant này.
3. Giải thích ý nghĩa nghiệp vụ của case đó bằng 1 câu — tại sao boundary này quan trọng với người dùng thật, không chỉ để "làm hài lòng công cụ mutation testing".
4. Không sửa code implementation, chỉ đề xuất test.
Yêu cầu số 3 trong prompt trên rất quan trọng và thường bị bỏ qua. Rủi ro lớn nhất khi để AI tự do viết test nhắm vào mutant là overfitting theo mutation operator — AI học được rằng Stryker hay đổi >= thành >, && thành ||, nên nó sinh ra hàng loạt test kiểm tra đúng những biến thể toán tử đó mà không thực sự hiểu tại sao boundary đó có ý nghĩa nghiệp vụ. Kết quả là mutation score tăng đẹp trên báo cáo, nhưng test suite lại phình to với các case vô nghĩa, khó bảo trì, và không phản ánh invariant thật của domain.
Cách phòng tránh: luôn bắt AI giải thích ý nghĩa nghiệp vụ trước khi chấp nhận test case nó đề xuất (như yêu cầu #3), và tự hỏi "nếu tôi xoá dòng comment giải thích lý do, test case này còn tự đứng vững như một kịch bản thật không?". Nếu câu trả lời là không — ví dụ test chỉ để "cho path này chạy qua nhánh else" — nên bỏ hoặc viết lại thành property test tổng quát hơn thay vì hard-code một ví dụ.
Mẹo: Sau khi AI đề xuất test diệt mutant, chạy lại mutation testing một lần nữa trước khi merge — đôi khi test mới do AI viết chỉ diệt được đúng mutant cụ thể đó nhưng để lộ ra mutant khác chưa từng xuất hiện trong báo cáo (do thứ tự chạy mutation thay đổi).
Property-based test bắt lỗi hallucination của AI như thế nào
Khi một AI coding agent viết cả implementation lẫn test cho cùng một tính năng, có một rủi ro tinh vi: nếu code implementation "hallucinate" — tức là trông hợp lý, biên dịch được, chạy được, nhưng sai logic ở một khía cạnh nào đó (off-by-one, sai dấu, làm tròn nhầm chiều) — thì rất có khả năng bộ test example-based do chính AI đó viết cũng mang cùng một điểm mù, vì cả hai được sinh ra từ cùng một "trực giác" sai lệch của model.
Ví dụ cụ thể: bạn yêu cầu AI viết hàm calculateProration và test đi kèm. AI implement công thức tính credit theo Math.floor thay vì Math.round ở bước quy đổi ngày còn lại sang tiền — một lỗi làm tròn tưởng như vô hại, khiến công ty mất vài cent mỗi giao dịch (nhân với hàng triệu giao dịch/tháng, đây là tiền thật). Vì AI implement bằng floor, khi được yêu cầu viết test, nó thường chọn input mà floor và round cho cùng kết quả (ví dụ số chia hết), nên test pass — nó "tự xác nhận" cho chính lỗi của mình mà không biết.
Property-based test phá vỡ vòng lặp này vì nó được định nghĩa từ invariant nghiệp vụ độc lập với implementation — property "tổng charge và credit phải bảo toàn giá trị gốc trong sai số 1 cent" không quan tâm implementation dùng floor hay round, nó chỉ kiểm tra kết quả cuối có đúng về mặt toán học hay không. Khi framework sinh ra hàng nghìn cặp (cycleDays, daysUsed) ngẫu nhiên, chỉ cần một cặp mà floor và round lệch nhau đủ để vượt ngưỡng sai số cho phép, property test sẽ fail và shrinking sẽ đưa bạn thẳng đến input tối giản gây lỗi.
Nói cách khác, property-based test đóng vai trò như một đối thủ độc lập (adversarial check) — nó không được viết dựa trên "AI nghĩ implementation nên hoạt động thế nào" mà dựa trên "nghiệp vụ yêu cầu điều gì phải luôn đúng". Đây là lý do property nên được con người (hoặc ít nhất được con người review kỹ) định nghĩa từ business invariant trước khi implementation được viết — tương tự tinh thần TDD nhưng ở tầng invariant thay vì tầng ví dụ cụ thể.
Một pattern hay dùng trong team mình: yêu cầu hai agent khác nhau (hoặc hai lần gọi AI với context tách biệt) — một agent viết implementation, một agent khác (không thấy code implementation) chỉ nhận business invariant và viết property test. Sự tách biệt này giảm đáng kể khả năng "đồng lõa" giữa code và test.
Mẹo: Nếu bắt buộc dùng cùng một AI session để viết cả code lẫn property test, hãy yêu cầu nó viết property test TRƯỚC khi thấy implementation — dựa thuần trên spec nghiệp vụ bạn cung cấp — rồi mới yêu cầu viết implementation để pass property đó.
Thực hành: Viết property và chạy mutation testing cho bộ tính Proration
Bây giờ ráp toàn bộ lại thành một bài thực hành trọn vẹn cho Proration Calculator — tính lại phí khi user upgrade/downgrade subscription giữa chu kỳ billing.
Bước 1 — Spec nghiệp vụ. Giả sử hàm nhận vào: oldPlanPrice, newPlanPrice (đơn vị cents, tránh float), cycleDays (số ngày chu kỳ hiện tại), daysUsed (số ngày đã dùng gói cũ trước khi đổi). Trả về { credit, charge } — số tiền hoàn từ gói cũ và số tiền phải trả thêm cho gói mới, tính theo tỷ lệ số ngày còn lại.
Bước 2 — Liệt kê property từ invariant nghiệp vụ:
// property 1 — bảo toàn giá trị (không tạo/mất tiền)
// charge - credit ≈ (newPlanPrice - oldPlanPrice) * daysRemaining / cycleDays
// property 2 — non-negativity
// credit >= 0 và charge >= 0 luôn đúng, kể cả khi downgrade mạnh
// property 3 — monotonicity theo daysUsed
// daysUsed tăng => daysRemaining giảm => |charge - credit| không tăng
// property 4 — round-trip: upgrade rồi downgrade về đúng gói cũ cùng ngày
// phải cho kết quả ròng gần bằng 0 (sai số làm tròn tối đa 1 cent mỗi bước)
Bước 3 — Code property test bằng fast-check:
import fc from 'fast-check';
import { calculateProration } from './proration';
describe('calculateProration - properties', () => {
const cycleAndUsed = fc
.integer({ min: 1, max: 366 })
.chain((cycleDays) =>
fc.record({
cycleDays: fc.constant(cycleDays),
daysUsed: fc.integer({ min: 0, max: cycleDays }),
})
);
test('property: non-negativity', () => {
fc.assert(
fc.property(
cycleAndUsed,
fc.integer({ min: 0, max: 500_00 }),
fc.integer({ min: 0, max: 500_00 }),
({ cycleDays, daysUsed }, oldPlanPrice, newPlanPrice) => {
const { credit, charge } = calculateProration({ oldPlanPrice, newPlanPrice, cycleDays, daysUsed });
expect(credit).toBeGreaterThanOrEqual(0);
expect(charge).toBeGreaterThanOrEqual(0);
}
)
);
});
test('property: round-trip upgrade rồi downgrade về giá cũ', () => {
fc.assert(
fc.property(
cycleAndUsed,
fc.integer({ min: 0, max: 500_00 }),
fc.integer({ min: 0, max: 500_00 }),
({ cycleDays, daysUsed }, priceA, priceB) => {
const up = calculateProration({ oldPlanPrice: priceA, newPlanPrice: priceB, cycleDays, daysUsed });
const down = calculateProration({ oldPlanPrice: priceB, newPlanPrice: priceA, cycleDays, daysUsed });
const net = (up.charge - up.credit) + (down.charge - down.credit);
expect(Math.abs(net)).toBeLessThanOrEqual(2); // sai số làm tròn 2 bước
}
)
);
});
});
Bước 4 — Chạy mutation testing với các unit test hiện có (example-based) cộng property test mới. Cấu hình Stryker tối thiểu:
{
"mutate": ["src/billing/proration.ts"],
"testRunner": "jest",
"reporters": ["html", "clear-text"],
"thresholds": { "high": 90, "low": 70, "break": 60 }
}
Chạy npx stryker run. Giả sử kết quả trả về mutation score 78%, với các surviving mutant sau:
- Mutant đổi
Math.roundthànhMath.floorở bước tínhcredit— sống sót vì không test nào assert đúng giá trị làm tròn tại boundary.5. - Mutant đổi
daysRemaining = cycleDays - daysUsedthànhdaysRemaining = cycleDays - daysUsed - 1— sống sót vì property round-trip vẫn "vô tình" pass do sai số bù trừ giữa hai chiều upgrade/downgrade (dấu hiệu property round-trip che giấu lỗi off-by-one một chiều, cần thêm property kiểm tra riêngdaysRemainingtạidaysUsed = 0vàdaysUsed = cycleDays).
Bước 5 — Đưa surviving mutant cho AI, xin đề xuất test, review theo tiêu chí ở mục 5, rồi merge test mới, chạy lại Stryker để xác nhận mutation score tăng lên (ví dụ 78% → 94%) và không phát sinh mutant equivalent giả.
Bài thực hành này minh hoạ đúng vòng lặp bạn sẽ dùng trong dự án thật: viết property từ invariant → chạy mutation testing để đo độ mạnh thực sự của suite → dùng AI đọc báo cáo và đề xuất test bổ sung → review để tránh overfitting → lặp lại cho tới khi mutation score ổn định ở mức chấp nhận được (thường 80–90% cho module tài chính, không cần và không nên ép 100%).
Mẹo: Đừng đặt mục tiêu mutation score 100% cho toàn bộ codebase — dành ngân sách thời gian cho các module tính tiền, xác thực, phân quyền; các module UI/formatting ít rủi ro hơn nhiều và không đáng chi phí compute + thời gian review.
Những điểm chính
- Example-based test chỉ chứng minh đúng tại các điểm bạn nghĩ tới; AI sinh test có xu hướng tập trung vào happy path vì chia sẻ trực giác "input điển hình" với chính nó.
- Property-based testing kiểm tra invariant nghiệp vụ (bảo toàn giá trị, non-negativity, round-trip, monotonicity, idempotency) trên hàng nghìn input sinh ngẫu nhiên, thay vì vài ví dụ cụ thể.
- fast-check (JS/TS), Hypothesis (Python), proptest (Rust) đều dựa trên hai trụ cột: arbitrary/generator để sinh input, và shrinking để rút gọn counterexample về dạng tối giản dễ debug.
- Coverage phần trăm dòng không đo được độ mạnh thật của test; mutation testing (Stryker, PIT, mutmut) đo bằng cách mutate code và xem test có "kill" được thay đổi hành vi hay không — mutation score là chỉ số đáng tin hơn nhiều.
- Mutation testing tốn compute đáng kể — nên giới hạn phạm vi vào module critical hoặc diff của PR, chạy như job riêng thay vì chặn CI chính.
- AI agent giúp đọc báo cáo surviving mutant và đề xuất test rất hiệu quả, nhưng có rủi ro overfitting theo mutation operator — luôn bắt AI giải thích ý nghĩa nghiệp vụ trước khi chấp nhận test case.
- Property-based test đóng vai trò đối thủ độc lập giúp bắt lỗi hallucination của AI trong implementation, đặc biệt khi cùng một AI viết cả code lẫn test — tách biệt việc viết property khỏi việc thấy implementation để tăng độ tin cậy.
- Cả hai kỹ thuật đều mạnh nhưng không miễn phí: cần đầu tư thời gian học cách viết property tốt, và cần cảnh giác với false confidence nếu áp dụng hời hợt hoặc để AI tự động hoá mà không review.