Sinh test bằng AI dễ, giữ cho test suite đó sống khoẻ qua hàng chục lần release mới là bài toán khó. Thực tế phổ biến ở nhiều team: sau 2-3 tháng dùng AI sinh test ào ạt, test suite phình to nhưng tỷ lệ flaky (không ổn định) tăng, số test bị skip tăng, và không ai còn dám xoá test cũ vì không chắc nó còn cần thiết hay không. Nguyên nhân gốc thường không phải do AI sinh test tệ ngay từ đầu, mà do thiếu một vòng lặp bảo trì (maintenance loop) chủ động — và đây chính là nơi AI, nếu dùng đúng cách, giúp ích nhiều hơn ở việc duy trì so với việc chỉ sinh mới.
Bài này tập trung vào bốn năng lực: phát hiện sớm khi test lệch khỏi ứng dụng thật, dùng AI update test sau khi UI/API đổi, refactor test cũ để dễ đọc và bền hơn, và xây một workflow bảo trì có AI hỗ trợ mà team có thể áp dụng lâu dài.
Làm Thế Nào Để Phát Hiện Khi Test Do AI Sinh Không Còn Khớp Với Ứng Dụng?
Test suite "lệch" (drift) khỏi ứng dụng theo nhiều cách âm thầm: selector còn tồn tại trong test nhưng đã bị xoá khỏi component thật, endpoint API đổi field response nhưng test assertion cũ vẫn pass nhờ optional chaining "che" lỗi, hoặc test giả lập một luồng nghiệp vụ đã không còn đúng với UX hiện tại. Vấn đề là các dạng lệch này thường không làm test fail ngay — chúng làm test false positive (báo pass nhưng không thực sự kiểm tra đúng cái cần kiểm tra), nguy hiểm hơn nhiều so với test fail rõ ràng.
Mẹo: Đừng chỉ tin vào "test đang pass" làm tín hiệu duy nhất về sức khoẻ test suite. Định kỳ (ví dụ hàng tuần) chạy một audit riêng để đối chiếu chéo giữa test code và app code thật — pass rate cao có thể đang che giấu một tỷ lệ lớn test đã hoá "zombie" (chạy được nhưng không còn kiểm tra đúng logic gốc).
Ba Lớp Phát Hiện (The Three Detection Layers)
Nên tổ chức việc phát hiện drift theo ba lớp độc lập, vì mỗi lớp bắt được loại lỗi khác nhau:
Lớp 1 — Đối chiếu selector. So khớp tĩnh giữa selector dùng trong test và selector thực tồn tại trong code/DOM đã build. Đây là lớp rẻ nhất, chạy nhanh, không cần chạy app.
grep -rhoE "getByTestId\(['\"]([a-zA-Z0-9_-]+)['\"]" tests/ \
| sed -E "s/getByTestId\(['\"]([a-zA-Z0-9_-]+)['\"]/\1/" \
| sort -u > /tmp/testids_used_in_tests.txt
grep -rhoE 'data-testid="[a-zA-Z0-9_-]+"' dist/ \
| sed -E 's/data-testid="([a-zA-Z0-9_-]+)"/\1/' \
| sort -u > /tmp/testids_in_app.txt
comm -23 /tmp/testids_used_in_tests.txt /tmp/testids_in_app.txt > /tmp/orphaned_selectors.txt
echo "Selector nghi vấn đã bị xoá khỏi app nhưng vẫn còn dùng trong test:"
cat /tmp/orphaned_selectors.txt
Lớp 2 — Đối chiếu schema API. So khớp giữa field mà test assert trong response và field thực có trong OpenAPI spec/contract hiện tại (đã bàn kỹ ở bài về contract testing của module này) — dùng AI để đọc diff giữa spec cũ và spec mới, liệt kê field bị đổi tên/xoá, rồi rà lại toàn bộ assertion liên quan.
Lớp 3 — Chạy thực tế và phân loại lỗi bằng AI. Chạy toàn bộ suite, thu log lỗi, rồi đưa cho AI phân loại: lỗi do app thay đổi hợp lệ (test cần update), lỗi do bug thật trong app (giữ nguyên test, báo bug), hay lỗi do flaky/timing (cần fix test, không phải app).
Đây là log lỗi của 18 test Playwright fail trong lần chạy CI gần nhất
(paste toàn bộ stack trace + error message).
Phân loại từng test fail vào 1 trong 3 nhóm:
1. "APP_CHANGED" - lỗi do UI/API thay đổi hợp lệ, test cần được update
2. "REAL_BUG" - lỗi phản ánh đúng một bug thật trong application
3. "FLAKY_TEST" - lỗi do vấn đề timing/race condition/thứ tự test, không
liên quan tới logic nghiệp vụ
Với mỗi test, nêu rõ dòng log nào dẫn tới kết luận phân loại đó. Nếu không
đủ thông tin để phân loại chắc chắn, ghi "CẦN ĐIỀU TRA THÊM" và giải thích
thiếu thông tin gì.
Xây Báo Cáo "Test Health" Có AI Hỗ Trợ
Kết hợp ba lớp trên thành một báo cáo định kỳ, AI đóng vai trò tổng hợp và viết nhận xét — không phải chạy kiểm tra (kiểm tra vẫn phải dùng script xác định, đảm bảo chính xác), mà là đọc kết quả các lớp và diễn giải mức độ nghiêm trọng, xếp thứ tự ưu tiên xử lý.
Dưới đây là kết quả 3 lớp kiểm tra test health tuần này:
Lớp 1 (orphaned selectors): 4 selector không còn trong app
<paste danh sách>
Lớp 2 (API schema drift): 2 field bị đổi tên trong /api/v2/orders
<paste diff>
Lớp 3 (phân loại lỗi chạy thực tế): 18 test fail, 11 APP_CHANGED,
3 REAL_BUG, 4 FLAKY_TEST
<paste chi tiết>
Viết báo cáo "Test Health Report" tuần này dạng Markdown, gồm:
- Tóm tắt điểm sức khoẻ tổng quan (Tốt/Cần chú ý/Nguy cấp) kèm lý do
- Danh sách việc cần làm, sắp theo thứ tự ưu tiên (REAL_BUG luôn ưu tiên
cao nhất vì ảnh hưởng sản phẩm thật, sau đó APP_CHANGED, cuối cùng FLAKY)
- Ước lượng effort xử lý mỗi nhóm (giờ công, ước lượng thô)
Làm Thế Nào Để Dùng AI Update Test Fail Sau Khi UI/API Thay Đổi?
Khi đã xác định rõ nhóm "APP_CHANGED" ở phần trên, bước tiếp theo là sửa test — và đây là việc AI làm rất nhanh nếu bạn cung cấp đúng diff của thay đổi, thay vì bắt AI tự đoán cái gì đã đổi.
Mẹo: Luôn đưa diff thật (git diff của component/API liên quan) vào prompt update test, đừng chỉ mô tả bằng lời "nút Submit đổi tên thành Xác nhận". Diff thật cho AI thấy chính xác điều gì đổi và điều gì giữ nguyên — giảm hẳn tình trạng AI "sửa quá tay" các phần không liên quan.
Prompt Update Nhận Biết Thay Đổi (Change-Aware Update Prompt)
Test Playwright sau đang fail vì component CheckoutForm đã được sửa
(paste toàn bộ nội dung test hiện tại):
<paste nội dung file checkout.spec.ts>
Đây là git diff của component liên quan gây ra thay đổi hành vi:
<paste git diff của CheckoutForm.tsx>
Yêu cầu:
1. Xác định chính xác dòng test nào bị ảnh hưởng bởi diff trên
2. Sửa CHỈ những dòng bị ảnh hưởng, giữ nguyên toàn bộ phần còn lại của test
(kể cả comment, thứ tự, style code hiện có)
3. Nếu diff thêm field mới bắt buộc (ví dụ field "note" giờ required), thêm
assertion/test case mới cho field đó thành một test riêng, không nhồi vào
test hiện có
4. Liệt kê rõ danh sách các thay đổi đã thực hiện kèm lý do, dạng bullet list,
trước khi in code đã sửa
Update Hàng Loạt Sau Khi API Migrate Version
Khi API đổi từ v1 sang v2 (đổi field, đổi endpoint path, đổi cấu trúc response bao ngoài), số lượng test bị ảnh hưởng thường rất lớn — đây là trường hợp AI có lợi thế rõ rệt về tốc độ so với sửa tay, miễn là bạn kiểm soát tốt scope của từng lượt sửa.
API đã migrate từ v1 sang v2 với các thay đổi sau (bảng mapping):
| v1 | v2 | Ghi chú |
|---|---|---|
| GET /api/orders | GET /api/v2/orders | path đổi |
| response.data.items | response.items | bỏ 1 lớp bao ngoài "data" |
| order.total_price | order.totalPrice | đổi snake_case -> camelCase |
| order.customer_id | order.customerId | đổi snake_case -> camelCase |
| Không có | order.currency (mới, required) | field mới, mặc định "VND" |
Dưới đây là danh sách 9 file test pytest dùng API v1 (paste nội dung từng file
hoặc để tôi paste từng file một khi bạn yêu cầu).
Với MỖI file, áp dụng đúng bảng mapping trên, không đổi thứ gì khác. Với field
mới "currency", chỉ thêm assertion kiểm tra field này tồn tại và có giá trị
hợp lệ (không giả định nó luôn là "VND" trừ khi test case đó thực sự test
đơn hàng VND).
Xử lý xong 1 file, dừng lại chờ tôi xác nhận trước khi qua file kế tiếp.
Yêu cầu "dừng lại chờ xác nhận" là chi tiết quan trọng — sửa hàng loạt không giám sát rất dễ lan lỗi giống nhau ra toàn bộ 9 file nếu AI hiểu sai một điểm trong mapping.
Dùng AI Xử Lý Việc Migrate Selector
Đổi chiến lược selector (ví dụ team quyết định chuyển từ CSS selector/XPath cũ sang data-testid chuẩn hoá toàn bộ) là việc cơ học, lặp lại nhiều, rất hợp để giao AI xử lý theo batch có kiểm soát.
Dự án đang chuẩn hoá lại selector: chuyển toàn bộ page.locator('.btn-submit'),
page.locator('#login-email') dạng CSS/ID sang page.getByTestId(...), theo
bảng mapping selector cũ -> data-testid mới (paste bảng mapping đã audit
từ codebase thật).
Với file tests/auth/login.spec.ts sau, thay toàn bộ selector theo mapping,
CHỈ đổi phần selector, không đổi logic test, không đổi assertion, không đổi
comment. Nếu gặp selector không có trong bảng mapping, GIỮ NGUYÊN và đánh dấu
bằng comment "// TODO: chưa có mapping data-testid, cần audit thêm" ngay
phía trên dòng đó — không tự đoán data-testid.
Yêu cầu rõ "không tự đoán" khi thiếu mapping là điểm mấu chốt — nếu không chỉ định, model có xu hướng tự bịa ra một data-testid nghe hợp lý nhưng không tồn tại thật, khiến test fail vì lý do hoàn toàn khác (selector không tìm thấy) thay vì lỗi logic.
Kiểm Chứng Đề Xuất Sửa Của AI
Không bao giờ merge trực tiếp bản sửa của AI mà không chạy lại. Quy trình kiểm chứng tối thiểu gồm ba bước: (1) chạy diff giữa bản cũ và bản AI sửa, đọc từng dòng thay đổi — không đọc lướt; (2) chạy lại chính xác test đã sửa ở môi trường thật (không chỉ tin log AI tự mô tả "test sẽ pass"); (3) chạy luôn các test lân cận trong cùng file/describe block để chắc chắn AI không vô tình phá test khác khi sửa các dòng chung (ví dụ beforeEach dùng chung).
npx playwright test tests/checkout/checkout.spec.ts --grep "should apply discount code" --reporter=line
npx playwright test tests/checkout/checkout.spec.ts --reporter=line
Làm Thế Nào Để Refactor Test Do AI Sinh Để Dễ Đọc và Bền Lâu Hơn?
Test do AI sinh nhanh trong giai đoạn đầu, nhưng thường có mùi code (code smell) đặc trưng: lặp lại logic setup giữa các file, hardcode giá trị chờ (waitForTimeout), hoặc đặt tên test mô tả hành động thay vì mô tả hành vi nghiệp vụ kỳ vọng. Refactor định kỳ là bước bắt buộc, không phải "nice to have".
Mẹo: Chạy refactor theo từng loại code smell một lần (ví dụ tuần này chỉ refactor phần page object, tuần sau mới refactor naming convention) — refactor gộp nhiều mối quan tâm trong một PR khiến review khó, và khó rollback nếu một loại refactor gây regression.
Prompt Audit Refactor
Đọc toàn bộ các file trong tests/e2e/checkout/ (paste nội dung hoặc liệt kê
để tôi cung cấp từng file).
Audit và liệt kê các code smell theo các nhóm sau, không sửa code, chỉ liệt
kê kèm số dòng và file:
1. Setup logic lặp lại giữa >= 2 file test có thể trích ra helper/fixture chung
2. waitForTimeout hoặc sleep cứng (hardcoded wait) nên thay bằng wait điều
kiện (waitForSelector, expect().toBeVisible với timeout hợp lý)
3. Test case đặt tên theo hành động ("test login button click") thay vì theo
hành vi nghiệp vụ kỳ vọng ("user with valid credentials is redirected to
dashboard")
4. Assertion yếu (chỉ check status code 200 mà không check nội dung response,
hoặc chỉ check element tồn tại mà không check nội dung/state của nó)
5. Test phụ thuộc thứ tự chạy (dùng lại state từ test trước, không tự setup
độc lập)
Xuất kết quả dạng bảng, không sửa code ở bước này.
Trích Pattern Chung Vào Page Object
Sau khi audit xác định các đoạn setup lặp lại, giao AI trích xuất thành Page Object Model (POM) — nhưng luôn review kỹ interface public của page object trước khi cho áp dụng hàng loạt, vì đây sẽ là điểm chạm của rất nhiều test sau này.
Từ 6 file test trong tests/e2e/checkout/ (đã paste ở bước trước), tôi thấy
đoạn code sau lặp lại ở cả 6 file (điền/submit form checkout):
<paste đoạn code lặp lại cụ thể, ví dụ 15-20 dòng fill input + click submit>
Trích đoạn này thành class CheckoutPage trong tests/e2e/pages/checkout.page.ts,
theo Page Object Model chuẩn của Playwright:
- Constructor nhận `page: Page`
- Method `fillShippingInfo(data: ShippingInfo)`, `applyDiscountCode(code: string)`,
`submitOrder(): Promise<void>`
- Method trả về Locator, không trả về element đã resolve, để giữ tính lazy
của Playwright locator
- Sau khi có class này, sửa lại 1 trong 6 file test (checkout-happy-path.spec.ts)
để dùng CheckoutPage, làm ví dụ mẫu cho tôi review trước khi áp dụng cho
5 file còn lại
Cải Thiện Tính Độc Lập Của Test
Test sau đang phụ thuộc test khác chạy trước để có state (không dùng
beforeEach để tự setup, mà giả định database đã có sẵn record từ test trước
trong cùng file):
<paste nội dung test có vấn đề>
Sửa lại để test này tự setup toàn bộ state nó cần trong beforeEach hoặc ngay
trong thân test, không phụ thuộc thứ tự chạy với test khác. Nếu cần tạo dữ
liệu qua API, dùng đúng fixture/helper hiện có của project (paste tên và
signature của helper createTestOrder() đã có sẵn trong project). Sau khi
sửa, xác nhận rõ test này có thể chạy độc lập bằng --grep chỉ riêng nó mà
vẫn pass.
Refactor Quy Ước Đặt Tên
Đặt tên nhất quán giúp việc lọc test theo tag/nhóm trong CI dễ hơn nhiều (ví dụ chạy riêng nhóm smoke test hay riêng nhóm test liên quan billing). Giao AI chuẩn hoá tên theo convention mới đã thống nhất trong team:
Team đã thống nhất convention đặt tên test mới:
"should [expected behavior] when [condition]"
Ví dụ đúng: "should show validation error when email format is invalid"
Ví dụ sai (cách cũ): "test invalid email", "email validation test 2"
Đổi tên toàn bộ test (describe/it) trong file tests/e2e/auth/login.spec.ts
theo convention mới, GIỮ NGUYÊN logic test, chỉ đổi chuỗi tên. Với mỗi tên
cũ/mới, liệt kê rõ trước khi in code, để tôi review nhanh không cần đọc lại
toàn bộ diff.
Một Workflow Bảo Trì Test Có AI Hỗ Trợ Tốt Trông Như Thế Nào?
Tất cả kỹ thuật trên chỉ có giá trị nếu được đóng khung thành một workflow lặp lại được, có lịch trình rõ ràng, không phụ thuộc vào việc "ai đó nhớ ra phải làm". Dưới đây là một khung workflow đã được nhiều team áp dụng thành công, gồm bốn thành phần.
Mẹo: Gắn việc bảo trì test vào chính quy trình sprint hiện có (ví dụ dành cố định 1 buổi mỗi sprint cho "test health day") thay vì coi nó là việc làm thêm ngoài giờ — việc này chỉ bền được khi nó có slot thời gian chính thức, được estimate và tính vào velocity.
Vòng Lặp Bảo Trì Hàng Tuần
Một vòng lặp hàng tuần tối giản gồm: (1) chạy script Lớp 1 + Lớp 2 phát hiện drift tự động vào sáng thứ Hai; (2) đưa kết quả cho AI tổng hợp Test Health Report; (3) team review report trong 15 phút đầu buổi standup hoặc một slot riêng; (4) phân việc sửa theo mức độ ưu tiên đã có trong report, ai nhận việc gì tự cập nhật vào issue tracker.
Tích Hợp CI Để Phát Hiện Drift Liên Tục
Thay vì chỉ chạy thủ công hàng tuần, nên đưa bước phát hiện Lớp 1 (đối chiếu selector) vào chính pipeline CI, chạy mỗi khi có PR đổi component hoặc file test, để bắt drift ngay tại thời điểm gây ra nó — rẻ hơn rất nhiều so với phát hiện sau vài tuần.
name: Test Health Check
on:
pull_request:
paths:
- 'src/**'
- 'tests/**'
jobs:
selector-drift-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Extract testids used in tests
run: |
grep -rhoE "getByTestId\(['\"]([a-zA-Z0-9_-]+)['\"]" tests/ \
| sed -E "s/getByTestId\(['\"]([a-zA-Z0-9_-]+)['\"]/\1/" \
| sort -u > /tmp/testids_used.txt
- name: Extract testids present in built app
run: |
grep -rhoE 'data-testid="[a-zA-Z0-9_-]+"' dist/ \
| sed -E 's/data-testid="([a-zA-Z0-9_-]+)"/\1/' \
| sort -u > /tmp/testids_in_app.txt
- name: Fail if orphaned selectors found
run: |
comm -23 /tmp/testids_used.txt /tmp/testids_in_app.txt > /tmp/orphaned.txt
if [ -s /tmp/orphaned.txt ]; then
echo "::error::Tìm thấy selector dùng trong test nhưng không còn tồn tại trong app:"
cat /tmp/orphaned.txt
exit 1
fi
echo "Không phát hiện selector orphaned."
- name: Run full test suite
run: npx playwright test --reporter=github
Quy Trình Phản Ứng Khi Test Fail (Failure Response Protocol)
Có một checklist phản ứng chuẩn giúp team không mất thời gian tranh luận mỗi lần test đỏ: (1) chạy lại test đó riêng lẻ 3 lần cục bộ để loại trừ flaky ngay từ đầu; (2) nếu ổn định fail, dùng AI phân loại theo 3 nhóm đã nêu ở phần đầu bài (APP_CHANGED/REAL_BUG/FLAKY_TEST); (3) nếu REAL_BUG, tạo issue kèm log đầy đủ, KHÔNG sửa test để test pass giả tạo; (4) nếu APP_CHANGED, áp dụng change-aware update prompt, review kỹ trước khi merge; (5) nếu FLAKY_TEST, đưa vào backlog riêng "flaky test debt", không quarantine vô thời hạn mà không có deadline sửa.
Theo Dõi Chỉ Số Nợ Test (Test Debt Metrics)
Để tránh việc bảo trì test suite dựa hoàn toàn vào cảm tính, nên theo dõi một số chỉ số định lượng theo thời gian: tỷ lệ test bị skip/quarantine trên tổng số test, số lượng selector orphaned phát hiện mỗi tuần (xu hướng tăng nghĩa là drift đang tăng nhanh hơn khả năng bảo trì), thời gian trung bình từ lúc test fail do APP_CHANGED tới lúc được sửa xong, và tỷ lệ test được AI hỗ trợ sửa nhưng bị revert lại sau đó (chỉ số này phản ánh chất lượng thật của quy trình review, không phải chất lượng AI).
Dựa trên dữ liệu test debt 4 tuần gần nhất:
Tuần 1: 3% test skip, 5 selector orphaned, TTR (time-to-resolve) trung bình
2.1 ngày, 0 revert
Tuần 2: 4% test skip, 8 selector orphaned, TTR 2.8 ngày, 1 revert
Tuần 3: 6% test skip, 12 selector orphaned, TTR 4.5 ngày, 3 revert
Tuần 4: 7% test skip, 15 selector orphaned, TTR 5.9 ngày, 2 revert
Phân tích xu hướng này, nhận định team đang cải thiện hay xuống dốc về test
debt, chỉ ra chỉ số nào đáng lo nhất, và đề xuất 2-3 hành động cụ thể để
đảo ngược xu hướng nếu đang xuống dốc.
Việc để AI đọc bảng số liệu và viết nhận định định kỳ giúp báo cáo sức khoẻ test suite trở thành một phần tự nhiên của retro sprint, thay vì một việc "làm cho có" không ai đọc.