Tôi từng để AI sửa một lỗi tưởng rất nhỏ: khi upload file lỗi, UI phải hiện message từ backend thay vì message generic. AI sửa component, thêm test, test pass. Nhưng khi chạy trên staging, message vẫn generic. Lý do: test mock hook trả thẳng error.message, trong khi code thật nhận error qua response.data.errors[0].detail. Test đã chứng minh một thế giới không tồn tại.
Sau lần đó, tôi đổi cách nghĩ về test khi dùng AI coding. Không phải “AI viết xong thì bảo nó thêm test”. Câu đúng hơn là: trước khi cho AI viết, mình phải biết lớp rủi ro nằm ở đâu, rồi yêu cầu test đánh vào lớp đó. Nếu test sai lớp, nó chỉ làm diff trông an toàn hơn.
Test strategy bắt đầu từ failure mode
Mỗi task nên có một failure mode chính. Ví dụ:
- User đổi filter nhưng URL không reset page.
- Backend trả 409 nhưng frontend hiện message 500.
- Permission check bỏ sót owner role.
- Retry job chạy hai lần và tạo duplicate record.
- Date timezone lệch một ngày.
Test tốt phải bắt đúng failure mode đó. Nếu bug nằm ở UI event flow, unit test pure function chưa đủ. Nếu bug nằm ở API contract, component snapshot không có giá trị. Nếu bug nằm ở transaction boundary, mock repository có thể che mất lỗi.
Tôi thường viết một dòng trước khi implement:
Regression target: khi backend trả 409 với detail, UI hiển thị detail đó và không retry upload.
Dòng này giúp cả người lẫn agent không lạc sang test dễ viết hơn.
Chọn test theo contract, không theo file vừa sửa
AI hay chọn test gần file vừa sửa. Sửa component thì thêm component test. Sửa service thì thêm service unit test. Cách này tiện nhưng chưa chắc đúng.
Tôi chọn theo contract bị ảnh hưởng:
- UI interaction contract: user làm gì, thấy gì, URL/state đổi ra sao.
- API contract: request/response shape, status code, error payload.
- Domain contract: rule nghiệp vụ nào phải đúng.
- Persistence contract: record nào được tạo/sửa/xóa, transaction có atomic không.
- Operational contract: retry, timeout, idempotency, logging, rollback.
Nếu task sửa mapping response từ API sang UI, test nên có fixture response giống API thật. Nếu task sửa permission, test nên assert role matrix. Nếu task sửa queue worker, test nên assert idempotency hoặc side effect count.
File vừa sửa chỉ là nơi bug biểu hiện. Contract mới là nơi test nên neo.
Mock càng gần bug càng nguy hiểm
Mock không xấu. Mock sai lớp mới xấu. Với code AI viết, tôi đặc biệt cảnh giác khi test mock đúng phần đang nghi ngờ.
Ví dụ bug liên quan API error shape. Nếu test mock hook đã normalize error sẵn, test không chứng minh gì về mapping thật. Bug liên quan cache key. Nếu test mock fetch result mà không đi qua query key, test không bắt regression. Bug liên quan permission. Nếu test mock isAllowed() trả true, test chỉ chứng minh button render khi được cho render.
Một rule tôi dùng: mock boundary bên ngoài hệ thống, không mock đoạn mình đang sửa. Frontend có thể mock network bằng fixture response thật. Backend có thể mock cổng thanh toán bên ngoài, nhưng đừng mock service chứa transaction rule cần test.
Fixture cũng nên giống data thật. AI thường tạo foo, bar, [email protected]. Với domain phức tạp, data giả vô nghĩa làm mất bug. Nếu production có status: "PENDING_REVIEW" và status: "PENDING_PAYMENT", test dùng status: "active" là vô dụng.
Regression test trước, coverage sau
Tôi không yêu cầu AI tăng coverage chung trong một bug fix. Tôi yêu cầu một regression test fail trên code cũ. Nếu có thời gian, thêm case cạnh đó. Nhưng regression test là phần không được thiếu.
Một flow tốt:
- Reproduce bằng test hoặc manual step.
- Chạy test thấy fail, nếu khả thi.
- Sửa code nhỏ nhất.
- Chạy test thấy pass.
- Chạy guardrail rộng hơn vừa đủ.
Không phải lúc nào cũng có điều kiện chạy bước 2 trên code cũ, nhất là khi đang làm trong dirty worktree. Nhưng về mặt review, test phải đọc như nó sẽ fail trước fix. Nếu test chỉ assert behavior mới do implementation tạo ra, nó có thể là test rỗng.
Coverage là metric phụ. Một diff có 95% coverage nhưng không test failure mode chính vẫn nguy hiểm. Một regression test 12 dòng đánh đúng bug có giá trị hơn nhiều.
Build, lint, typecheck là guardrail, không phải test thay thế
AI rất hay tạo lỗi cơ học: import thiếu, type sai nhẹ, markdown fence thiếu language, unused variable, dependency array không đúng. Build/lint/typecheck bắt được nhóm đó. Tôi luôn chạy guardrail phù hợp:
npm run typecheck
npm run lint
npm test -- OrderFilters
git diff --check -- src/features/orders/OrderFilters.tsx
Nhưng đừng nhầm guardrail với behavior test. TypeScript không biết user flow đúng hay sai. Lint không biết API contract có drift không. git diff --check chỉ bắt whitespace/trailing issue. Chúng cần thiết vì giảm noise, nhưng không thay review.
Trong blog repo, guardrail có thể là git diff --check và build Astro. Trong backend, có thể là unit test, integration test, migration dry run. Chọn theo blast radius. Đừng chạy toàn bộ CI 40 phút cho typo một bài markdown, nhưng cũng đừng chỉ chạy lint cho thay đổi payment retry.
Test contract cho API và hook có giá trị lớn
Khi AI sửa frontend dùng API có sẵn, tôi thích test ở lớp hook/adapter nếu project có pattern đó. Lý do: component test thường dễ mock quá sâu, còn API integration thật có thể nặng. Hook/adapter test với fixture response thật là điểm cân bằng tốt.
Ví dụ:
const response = {
errors: [{ detail: "File exceeds upload limit" }],
};
expect(mapUploadError(response)).toBe("File exceeds upload limit");
Đây không phải test toàn flow, nhưng nó khóa contract error shape. Sau đó component test chỉ cần chứng minh message đã map được sẽ render đúng. Hai test nhỏ ở hai seam tốt hơn một test lớn mock sai.
Với backend, contract test có thể assert request schema, response code, hoặc event emitted. Nếu AI đổi field name “cho gọn”, test contract phải fail.
Đừng để AI tự chọn hết test plan
Tôi thường không hỏi “add tests”. Tôi hỏi cụ thể:
Thêm regression test cho case backend trả 409 với errors[0].detail.
Không mock function mapUploadError.
Không thêm endpoint/hook mới.
Chạy npm test -- upload-error và báo result.
AI vẫn có thể đề xuất thêm test, nhưng plan test phải do người hiểu rủi ro chốt. Nếu không, AI sẽ tối ưu cho test dễ viết và dễ pass.
Một trick nhỏ: yêu cầu agent nói test nào sẽ fail trên code cũ. Câu trả lời này lộ rất nhiều. Nếu nó không giải thích được, test có thể chưa đúng regression.
Manual verification vẫn có chỗ đứng
Không phải thứ gì cũng đáng automate ngay. Với UI change nhỏ, tôi thường chạy app và kiểm bằng browser nếu flow quan trọng. Với markdown/blog, tôi xem render nếu có table, code block, SVG, hoặc frontmatter mới. Với incident fix, tôi đọc log shape và rollback command.
Manual verification nên cụ thể:
- Mở URL nào.
- Click gì.
- Expected state là gì.
- Console/network có lỗi không.
- Screenshot hoặc log nào chứng minh.
Đừng ghi “tested manually” trống không. Nó không giúp reviewer sau này.
Test strategy tối thiểu tôi dùng
Với mỗi AI-generated diff, tôi muốn có ít nhất một trong các bằng chứng:
- Regression test đánh đúng bug.
- Contract test giữ API/schema/domain rule.
- Typecheck/build/lint sạch cho vùng thay đổi.
- Manual verification cụ thể cho UI hoặc operational flow.
git diff --checksạch cho file text/markdown.
Không phải task nào cũng cần tất cả. Test strategy tốt là vừa đủ với blast radius. Sửa copy trong blog không cần integration test. Sửa auth không được chỉ dựa vào snapshot.
AI làm implementation rẻ hơn, nhưng không làm rủi ro biến mất. Test strategy là cách mình quyết định rủi ro nào đáng khóa bằng test, rủi ro nào chỉ cần guardrail, và rủi ro nào phải review thủ công. Nếu không quyết định phần đó, AI sẽ quyết định thay mình, thường theo hướng dễ pass nhất.