GĐ13 — Testing: chiến lược & tự động hoá
Kiểm chứng ngày 2026-10-05 (Node 24.21, Vitest 5.0.3, fast-check 4.10.2, PGlite 0.5.8 là Postgres chạy trong tiến trình, trong thư mục tạm): đã chạy
globalSetupvớiprovide/inject, guard URL, truncate động, test race, property-based và fake timers, kết quả ghi ở từng mục;tsc --strictsạch cho toàn bộ mã mới. Chưa chạy: Testcontainers (cần Docker), Prisma thật, k6 (không có trên máy kiểm tra; script k6 chỉ quanode --check), CI thật. Nguồn k6: open và closed model, constant-arrival-rate, dropped iterations.
Study note cho FE engineer (JS/TS mạnh) chuyển sang Backend. Mỗi concept: định nghĩa → tại sao quan trọng → cơ chế → ví dụ → pitfall. Ở FE, test hỏng thì UI lệch. Ở BE, test hỏng thì mất tiền của người thật, lộ dữ liệu của người thật. Và bạn không có ai bấm thử trước khi deploy — CI là người duy nhất chặn giữa code của bạn và production.
1. Test ở backend khác FE ở chỗ nào#
Định nghĩa. Test tự động = code chạy code, khẳng định hành vi, chạy lại được vô hạn lần.
Tại sao khác FE.
| FE | BE | |
|---|---|---|
| Trạng thái | Trong bộ nhớ, mất khi refresh | Bền vững trong DB — test này làm bẩn test kia |
| Đồng thời | 1 người dùng / 1 tab | Hàng nghìn request song song → race condition, deadlock |
| Hậu quả sai | Nút lệch chỗ | Trừ tiền hai lần, user A đọc dữ liệu user B |
| Phụ thuộc ngoài | API (mock được dễ) | DB, Redis, S3, Stripe, LLM — nhiều và có trạng thái |
| Người phát hiện lỗi | Người dùng thấy ngay | Có thể âm thầm nhiều tháng (dữ liệu sai dần) |
Cơ chế — thứ đáng test nhất ở BE, theo thứ tự:
- Authorization — user A có đọc/sửa được dữ liệu của user B không. Bug này im lặng và tệ nhất.
- Tính toán tiền/quota — làm tròn, tiền tệ, hạn mức.
- Ranh giới transaction — lỗi giữa chừng có rollback đúng không.
- Idempotency — gọi 2 lần có tạo 2 bản ghi không.
- Validation ở biên.
Pitfall. Đuổi theo 100% coverage bằng cách test getter/setter và mapper, trong khi không có một test nào kiểm tra "user thường không gọi được endpoint admin". Coverage cao, hệ thống vẫn thủng.
2. Phân tầng test — cái gì bao nhiêu#
Định nghĩa.
- Unit — một hàm/service cô lập, dependency được thay bằng test double. Mili-giây.
- Integration — nhiều thành phần thật ghép lại, DB thật. Chục–trăm mili-giây.
- E2E (API) — bắn HTTP thật vào app đã boot đầy đủ, đi qua middleware/guard/pipe. Trăm mili-giây.
Tại sao quan trọng. Kim tự tháp cổ điển (rất nhiều unit, ít integration) không hợp với backend CRUD. Lý do: phần lớn giá trị của một service backend nằm ở tương tác với DB — query đúng không, index có được dùng không, constraint có chặn không, transaction có rollback không. Mock DB đi thì test chỉ còn kiểm tra... cái mock.
Cơ chế — tỉ lệ thực dụng cho API service:
Quy tắc chọn tầng: có chạm DB → integration. Không chạm gì bên ngoài → unit. Đừng mock repository chỉ để gọi nó là "unit test".
Pitfall. Mock Prisma client. Bạn sẽ viết mockPrisma.user.findMany.mockResolvedValue([...]) và test pass — trong khi query thật thiếu where: { tenantId } và rò rỉ dữ liệu chéo tenant. Mock DB giấu đi đúng loại bug bạn cần bắt nhất.
3. Test runner — Vitest hay Jest#
Cơ chế & lựa chọn:
| Vitest | Jest | |
|---|---|---|
| ESM | Native, không cần cấu hình | Cần --experimental-vm-modules, hay vướng |
| TypeScript | Sẵn (esbuild) | Cần ts-jest (chậm) hoặc @swc/jest |
| Tốc độ | Nhanh hơn rõ rệt | Chậm hơn |
| NestJS | Mặc định của project mới từ Nest 12 | Mặc định của Nest cũ (trước 12); dự án đang dùng thì giữ |
| API | Gần như giống Jest (describe/it/expect) | Chuẩn de-facto |
Chốt — chọn theo framework, không chọn theo sở thích:
| Bạn đang dùng | Dùng | Vì sao |
|---|---|---|
| Express / Fastify / Node thuần, ESM | Vitest | ESM + TS chạy ngay, nhanh hơn rõ rệt, không có tầng transform để hỏng |
| NestJS 12 | Vitest (mặc định của project mới từ Nest 12, kiểm chứng ngày 2026-10-05) | Theo mặc định của framework; dự án Nest cũ đang dùng Jest thì giữ Jest, đừng đổi chỉ vì sở thích |
Lý do chốt như vậy — và đây là nguyên tắc dùng được cho mọi lựa chọn công cụ:
đừng chống lại framework: dùng runner mà framework tạo sẵn, vì tài liệu và ví dụ
đi theo nó. Với Nest, "runner mặc định" đã đổi từ Jest sang Vitest ở bản 12; kiểm tra
runner mà nest new tạo ra ở phiên bản bạn dùng.
Trong lộ trình này: DA1 đến DA4 dùng Vitest (snippet Jest ở GĐ07
đổi jest.fn() thành vi.fn()). Nếu bạn gặp dự án Nest cũ dùng Jest, phần còn lại của giai
đoạn này áp dụng nguyên vẹn.
Khi Jest chậm (dự án Nest cũ, test suite lớn dần): đổi transformer sang
@swc/jest trước — thường nhanh gấp nhiều lần ts-jest mà chỉ sửa vài dòng config.
Chỉ cân nhắc đổi hẳn sang Vitest khi đã thử cách này mà vẫn không đủ.
API gần như giống hệt nhau (describe/it/expect), nên mọi kỹ thuật ở phần còn
lại của giai đoạn này áp dụng được cho cả hai. Khác biệt thực tế chỉ nằm ở:
vi.* (Vitest) so với jest.*, và cách cấu hình.
Ví dụ — vitest.config.ts cho backend:
Phiên bản. Cấu hình trên đã chạy với Vitest 5.0.3 (kiểm chứng ngày 2026-10-05; Node ^22.12 || ^24 || >=26). Từ Vitest 4, poolOptions đã bị gỡ: các tuỳ chọn pool nay nằm ở cấp cao nhất của test, và poolOptions.threads.singleThread (hay poolOptions.forks.singleFork) không còn hiệu lực. Vitest in cảnh báo DEPRECATED nhưng vẫn chạy file test song song, nên bộ test chạm DB sẽ đỏ ngẫu nhiên mà không báo vì sao. Chạy tuần tự dùng maxWorkers: 1. Cấu hình cũ cũng không có tác dụng trên Vitest 3.2.7 (đã chạy: ba file test vẫn chạy song song, còn maxWorkers: 1 thì tuần tự), vì pool mặc định của Vitest 3 là forks, không phải threads (chưa kiểm trên Vitest 2 trở xuống). coverage.include cần từ Vitest 4: không khai thì file chưa từng được import không xuất hiện trong báo cáo, và ngưỡng coverage bị tính trên tập file bị thiếu.
Pitfall. Để environment: 'jsdom' (copy từ config FE) → globalThis.fetch, timer, và một số API Node bị thay thế bằng bản giả lập của jsdom → test hành xử khác production một cách khó hiểu.
4. Unit test & test double#
Định nghĩa. Test double là vật thay thế dependency. Bốn loại hay bị gọi nhầm là "mock":
- Stub — trả giá trị định sẵn. (
findByIdluôn trả user X) - Fake — cài đặt thật nhưng đơn giản. (repository lưu vào
Map) - Spy — bản thật, có ghi lại lời gọi.
- Mock — được khẳng định là đã bị gọi đúng cách.
Tại sao quan trọng. Dùng mock (khẳng định lời gọi) là ràng buộc test vào cách cài đặt. Đổi cách viết mà hành vi không đổi → test vẫn đỏ. Đó là test giòn. Ưu tiên stub/fake + khẳng định trên kết quả.
Ví dụ — logic thuần, đáng unit test:
Ví dụ — fake tốt hơn mock:
Pitfall. expect(mockRepo.save).toHaveBeenCalledWith(...) là test rằng "code gọi hàm này". Nếu bạn đổi từ save() sang upsert() mà kết quả y hệt, test đỏ dù không có gì hỏng. Chỉ khẳng định lời gọi khi hiệu ứng phụ chính là điều cần kiểm (ví dụ: "có gửi mail không") và không quan sát được cách khác.
Property-based test: kiểm bất biến thay vì vài ví dụ#
Test theo ví dụ kiểm những đầu vào bạn nghĩ ra. Property-based test (thư viện fast-check) khai báo một bất biến đúng với mọi đầu vào hợp lệ, rồi sinh hàng trăm đầu vào ngẫu nhiên; khi vi phạm, nó thu nhỏ (shrink) về phản ví dụ nhỏ nhất. Hợp với logic thuần: tính tiền, phân trang, mã hoá/giải mã cursor.
Điều thú vị là chỗ nó vỡ. Cho percentOff chạy tới 150 (thiếu validate ở biên vào), cùng bất biến "không âm" đỏ ngay (đã chạy):
Tạm tính 3, giảm 134% thì giảm floor(4,02) = 4, tổng -1. Sửa ở biên vào (z.number().int().min(0).max(100)), không sửa trong hàm tính. Ba thói quen đi kèm: miền sinh dữ liệu phải khớp miền hợp lệ của hàm (nếu không bạn kiểm bất biến của đầu vào không bao giờ xảy ra); ghi seed khi đỏ trên CI (fast-check in seed và path; phát lại bằng fc.assert(prop, { seed, path })); và ghim phản ví dụ thành một test thường để hồi quy không phụ thuộc may rủi.
Mức kiểm chứng: ba bất biến xanh (hai trong đoạn mã, thêm bất biến giảm 0% chạy ở bản kiểm) và một bất biến đỏ như trên đã chạy với fast-check 4.10.2 trên Vitest 5.0.3; tsc --strict sạch.
5. Integration test — DB thật, cô lập thật#
Định nghĩa. Chạy test với Postgres/Redis thật, thường bằng Testcontainers (thư viện tự bật container Docker cho mỗi lần chạy test rồi dọn sạch).
Tại sao quan trọng. Đây là nơi bắt được: unique constraint, cascade delete, transaction rollback, null ordering, timezone, và query thiếu điều kiện tenant. Không mock nào thay được.
Ví dụ — setup Testcontainers bằng globalSetup:
Đặt khởi tạo container trong beforeAll của setupFiles thì mỗi file test bật một container và chạy lại migration: chậm, và nhiều container cùng sống. globalSetup chạy một lần cho cả lần chạy; giá trị cấp qua project.provide và mỗi file test lấy bằng inject; hàm trả về là teardown. Chạy CHÍNH migration của production: test luôn cả tính đúng của migration (db push nhanh hơn nhưng bỏ qua migration, mất một lớp bảo vệ).
Mức kiểm chứng: globalSetup với provide/inject đã chạy trên Vitest 5.0.3 (URL giả, không có container): với hai file test, globalSetup chạy đúng một lần, setup.ts chạy hai lần, cả hai file đọc cùng dbUrl, teardown chạy một lần ở cuối; declare module 'vitest' cho inject('dbUrl') kiểu string qua tsc --strict. Phần container, migrate deploy và Prisma là code tham chiếu, chưa chạy (cần Docker).
Cơ chế — 3 chiến lược cô lập giữa các test:
| Chiến lược | Cách làm | Tốc độ | Nhược |
|---|---|---|---|
| Truncate | TRUNCATE ... CASCADE sau mỗi test | Trung bình | Phải liệt kê bảng; reset sequence |
| Transaction rollback | Mở transaction trước test, rollback sau | Nhanh nhất | Không test được code tự mở transaction (nested) |
| Schema/DB per worker | Mỗi worker một schema riêng | Nhanh, song song thật | Setup phức tạp hơn |
Ví dụ — truncate (mặc định an toàn, khuyên dùng để bắt đầu):
Ví dụ — test thật sự đáng giá (authorization + tenant isolation):
Pitfall. Chạy test trên database dev của chính bạn. Một lần TRUNCATE CASCADE là mất sạch dữ liệu đang làm việc. Tệ hơn: DATABASE_URL trỏ nhầm staging. Luôn dùng container riêng, và đặt hai chốt trong test/db-guard.ts:
Chốt thứ nhất kiểm URL mà Prisma sẽ thực sự dùng (đúng chuỗi đưa vào adapter) bằng cách phân tích URL: host phải nằm trong danh sách, tên DB phải kết thúc bằng _test. Regex dò chuỗi con trong cả URL, kiểu /localhost|127\.0\.0\.1|testcontainers/, lọt cả postgres://u:p@localhost.evil.com/prod lẫn postgres://u:p@db.prod.example.com/app?note=localhost (đã chạy: cả hai khớp regex, cả hai bị guard mới chặn). Host localhost cũng chưa đủ an toàn: đó vẫn có thể là DB dev của chính bạn (app_dev), nên chốt thứ hai hỏi server qua chính kết nối sắp TRUNCATE xem nó đang nối vào DB nào.
Mức kiểm chứng: cả hai hàm đã chạy trên Vitest 5.0.3; chốt thứ hai chạy trên PGlite (từ chối vì tên DB là postgres, không có đuôi _test) và trên một giả lập chỉ trả app_test cho nhánh cho qua, không phải Prisma thật.
6. Factory & fixture — dữ liệu test đọc được#
Định nghĩa. Factory = hàm tạo entity hợp lệ với giá trị mặc định, cho phép override phần bạn quan tâm.
Tại sao quan trọng. Không có factory, mỗi test có 20 dòng dựng dữ liệu, và không nhìn ra cái gì mới là quan trọng trong test đó.
Ví dụ:
Pitfall — faker với dữ liệu ngẫu nhiên không seed. faker.internet.email() thỉnh thoảng sinh trùng → test đỏ ngẫu nhiên 1/200 lần chạy, không tái hiện được. Tệ hơn: faker.number.int() có lúc rơi vào giá trị biên làm lộ bug thật — nhưng bạn không biết giá trị nào vì nó đã đổi ở lần chạy sau. Nếu dùng faker, luôn faker.seed(123) và in seed ra log khi test fail.
7. E2E API test với supertest#
Định nghĩa. Boot app thật, gửi HTTP request thật, kiểm tra response — đi qua toàn bộ middleware, guard, pipe, filter.
Tại sao quan trọng. Đây là tầng duy nhất chứng minh guard thật sự được gắn vào route. Service có kiểm quyền hoàn hảo cũng vô nghĩa nếu ai đó quên @UseGuards() trên controller.
Ví dụ — ma trận authorization (đáng giá nhất trong cả bộ test):
Vì sao hai dòng DELETE /documents/:id lại khác mã. Quy ước của lộ trình: trả 404 khi không muốn lộ sự tồn tại của tài nguyên (id thuộc tenant khác), 403 chỉ khi tồn tại là công khai (cùng tenant, thiếu quyền). Test này cố định quy ước đó; xem lập luận phía server ở GĐ12 mục 7. Với route kiểu /admin/users, sự tồn tại của route không phải bí mật nên 403 là đúng.
Pitfall. Viết E2E cho mọi trường hợp validation (30 test kiểm "email sai định dạng trả 400"). Chúng chậm gấp 50 lần unit test và kiểm cùng một thứ. E2E chỉ dành cho: luồng quan trọng, authz, và tích hợp giữa các tầng. Validation chi tiết để ở unit test của schema.
Test chạy trên trình duyệt (luồng giao diện, đăng nhập qua form) nằm ngoài phạm vi giai đoạn này; công cụ phổ biến là Playwright, xem playwright.dev.
8. Mock dịch vụ bên ngoài#
Định nghĩa. Thay HTTP call ra ngoài (Stripe, OpenAI, S3) bằng bản giả có kiểm soát.
Tại sao quan trọng. Gọi thật trong test = chậm, tốn tiền (LLM tính theo token!), không ổn định, và không tái hiện được lỗi (làm sao ép Stripe trả 500?).
Ví dụ — chặn ở tầng HTTP bằng MSW (giữ nguyên code sản phẩm):
Cơ chế onUnhandledRequest: 'error' — đây là cấu hình quan trọng nhất. Nó biến "test vô tình gọi API thật" từ lỗi âm thầm thành lỗi ồn ào.
Pitfall. Chỉ mock happy path. Trong sản phẩm thật, lỗi mới là chuyện thường: timeout, 429, 500, JSON sai định dạng, response cắt giữa chừng khi streaming. Với mỗi tích hợp ngoài, ít nhất phải có test cho: thành công, timeout, 429 + retry, và dữ liệu trả về sai định dạng.
9. Thời gian, ngẫu nhiên, ID — nguồn gốc test giòn#
Định nghĩa. Mọi thứ không tất định (non-deterministic) phải được tiêm vào (inject) chứ không gọi trực tiếp.
Ví dụ:
Ba bẫy của fake timers (đã chạy từng cái trên Vitest 5.0.3):
- Trả lại đồng hồ trong
afterEach, không phải ở cuối test. Nếu test đỏ trước dòngvi.useRealTimers()thì test kế tiếp vẫn thấy timer giả (vi.isFakeTimers()trảtrue) và đỏ theo, lỗi nằm ở test khác nên rất khó đoán. vi.useFakeTimers()mặc định giả cảsetTimeout. Codeawaitmột promise dùng timer nội bộ (driver DB, client HTTP,sleep) sẽ treo cho tới khi hết timeout của test. Chỉ cần đồng hồ thì dùngtoFake: ['Date'].- Khi buộc phải giả timer, dùng
advanceTimersByTimeAsync. Bản đồng bộadvanceTimersByTimechạy timer nhưng không xả microtask giữa các timer, nên chuỗiawaitphía sau chưa chạy khi bạn khẳng định.
Pitfall timezone. Test pass ở máy bạn (Asia/Ho_Chi_Minh, UTC+7) và đỏ trên CI (UTC) vì logic "hôm nay" lệch múi giờ. Ép timezone cố định cho test:
Và test riêng ở múi giờ khác cho logic ngày tháng (chi tiết ở GĐ14).
10. Test worker & luồng bất đồng bộ#
Định nghĩa. Test cho code chạy ngoài request: BullMQ worker, cron, outbox relay.
Tại sao quan trọng. Đây là chỗ ít được test nhất và hỏng nhiều nhất — vì lỗi không hiện ra ở response HTTP, chỉ nằm im trong log.
Cơ chế — tách logic khỏi hạ tầng queue:
Pitfall. Test end-to-end qua queue thật rồi await sleep(2000) chờ worker xong. Test chậm, và flaky: máy CI chậm hơn → 2 giây không đủ → đỏ ngẫu nhiên. Nếu buộc phải chờ, dùng polling có timeout (waitFor(() => expect(...)...)), không bao giờ sleep cố định.
Test race condition: ép hai request chồng lên nhau#
Lỗi kiểm rồi mới ghi (check-then-act) sống ở khe giữa hai bước: hai request cùng thấy "mã chưa dùng" rồi cùng ghi. Test thường không bắt được vì request chạy lần lượt. Hai điều kiện để test race có giá trị: (1) hai request phải thật sự chồng nhau ở đúng khe đó, nên dùng một cổng chặn (barrier) đặt giữa đọc và ghi thay vì cầu may; (2) test phải đỏ trên code sai trước khi bạn tin nó xanh trên code đúng.
Chạy với bản sai (đã chạy): expected [ { status: 'fulfilled', ... }, ... ] to have a length of 1 but got 2, tức cả hai request đều thắng. Đổi sang bản đúng thì xanh. Hai lưu ý:
- Fake nguyên tử
claimchỉ mô phỏng hợp đồng "một câu lệnh quyết định người thắng". Việc DB thật giữ hợp đồng đó phải kiểm bằng integration test trên Postgres:Promise.allSettled([redeem(id), redeem(id)])vớiprisma.coupon.updateMany({ where: { id, used: false }, data: { used: true } })rồi kiểmcount === 1. Pool kết nối phải có từ 2 kết nối trở lên, nếu không hai request tự xếp hàng và test xanh giả. - Cổng chặn có hạn chờ (ở đây 100 ms) để code đúng đắn dùng khoá hoặc hàng đợi, vốn không bao giờ để hai bên cùng tới khe, vẫn không bị treo.
Mức kiểm chứng: đã chạy trên Vitest 5.0.3 với cả hai bản (sai thì đỏ như trên, đúng thì xanh); tsc --strict sạch. Chưa chạy với Postgres thật.
11. Load test với k6#
Định nghĩa. Bắn tải có kịch bản vào hệ thống, đo latency phân vị và tỉ lệ lỗi dưới áp lực.
Tại sao quan trọng. Test chức năng chạy 1 request/lần — không bao giờ phát hiện: N+1 query, connection pool cạn, thiếu index, memory leak, race condition. Những thứ này chỉ hiện ra khi có đồng thời.
Ví dụ:
Cơ chế đọc kết quả. Nhìn hình dạng chứ không chỉ con số: latency tăng tuyến tính theo tải = tài nguyên bão hoà (thường là DB pool). Latency nhảy vọt đột ngột tại một ngưỡng = có hàng đợi đang đầy. Lỗi bắt đầu ở phút thứ 3 của giai đoạn giữ tải = rò rỉ tài nguyên (connection không đóng, memory leak).
Pitfall. Load test trên máy local có Docker Postgres 1 core rồi kết luận "hệ thống chịu được 50 RPS". Con số đó vô nghĩa. Load test chỉ có ý nghĩa trên môi trường giống prod, và giá trị chính là so sánh trước/sau khi tối ưu, không phải con số tuyệt đối.
Mô hình mở: tải theo tốc độ đến (arrival rate)#
Kịch bản stages ở trên là mô hình đóng: số VU (người dùng ảo) cố định, mỗi VU gửi request tiếp theo sau khi nhận xong cái trước. Khi server chậm đi, mỗi VU chờ lâu hơn nên tốc độ request tới server tự giảm đúng lúc nó đang yếu; tài liệu k6 gọi hiện tượng này là coordinated omission. Người dùng thật không chờ nhau: họ đến theo tốc độ riêng. Mô hình mở cố định số request mới mỗi giây, bất kể server chậm hay nhanh.
preAllocatedVUsdự trù theorate x latency dự kiến;maxVUslà trần của máy bắn tải. Hết VU rảnh thì k6 bỏ lượt và tăng bộ đếmdropped_iterations: server chậm tới mức không kịp nhận tải. Nếu drop xảy ra ngay đầu bài thì thường do thiếupreAllocatedVUs; nếu xảy ra giữa chừng thì thường do server đang suy giảm.- Dùng
ramping-arrival-rate(vớistartRate,stagesmục tiêu theo số request mỗitimeUnit) khi muốn tăng tải dần để tìm điểm gãy. - Dùng mô hình đóng khi hệ thống thật có số người dùng đồng thời bị chặn (ví dụ số kết nối cố định); dùng mô hình mở cho API công khai.
Mức kiểm chứng: script chỉ qua node --check (cú pháp); k6 không có trên máy kiểm tra nên chưa chạy; tên executor, tham số và metric đối chiếu tài liệu k6 (nguồn ở đầu file).
12. Coverage — dùng đúng cách#
Định nghĩa. Tỉ lệ dòng/nhánh code được chạy qua khi test.
Tại sao quan trọng — và nguy hiểm. Coverage đo code đã chạy, không đo hành vi đã được khẳng định. Một test gọi hàm mà không expect gì vẫn cho 100% coverage.
Cơ chế dùng đúng:
- Đọc coverage theo chiều ngược: tìm file quan trọng có coverage thấp (module thanh toán 20% → nguy hiểm thật), đừng chạy theo con số tổng.
- Đặt gate ở mức thấp và không giảm (ví dụ 70%), tăng dần. Gate 95% khiến người ta viết test rác để qua cửa.
- Quan tâm branch coverage hơn line coverage — nhánh
elsekhông chạy mới là chỗ ẩn bug.
Pitfall. Biến coverage thành KPI. Kết quả luôn giống nhau: người ta viết test cho getter/mapper để đẩy số lên, và code khó test (chính là code phức tạp, nhiều bug nhất) vẫn không có test.
13. Flaky test — chẩn đoán & trị#
Định nghĩa. Test lúc pass lúc đỏ mà code không đổi.
Tại sao quan trọng. Một test flaky làm hỏng toàn bộ giá trị của CI: đội ngũ học được thói quen "đỏ thì chạy lại" — và rồi bỏ qua cả những lỗi thật.
Cơ chế — nguyên nhân theo tần suất:
| Nguyên nhân | Dấu hiệu | Cách trị |
|---|---|---|
| Rò rỉ trạng thái giữa test | Đỏ khi đổi thứ tự / chạy song song | Truncate sau mỗi test; đừng dùng biến module-level |
| Phụ thuộc thời gian thật | sleep, so sánh Date.now() | Fake timers; polling thay vì sleep |
| Thứ tự không xác định | findMany không có ORDER BY | Luôn orderBy khi khẳng định thứ tự |
| Dữ liệu random | faker không seed | Seed cố định |
| Cổng/tài nguyên tranh chấp | Đỏ khi chạy song song | Port 0 (OS tự cấp); Testcontainers |
| Timezone/locale | Đỏ trên CI, xanh ở local | TZ=UTC cho cả test lẫn CI |
Ví dụ — bẫy thứ tự:
Pitfall. Bật retry: 3 trong config test để "hết flaky". Bạn vừa giấu đi một race condition có thật trong production. Test flaky thường là tin nhắn từ hệ thống rằng code của bạn cũng không tất định. Điều tra, đừng retry.
14. Gắn vào CI#
Ví dụ — GitHub Actions:
Cơ chế thứ tự. lint (giây) → typecheck (chục giây) → unit (giây) → integration (phút) → e2e. Bước rẻ nhất chạy trước để phản hồi nhanh nhất.
Pitfall. Cho phép merge khi CI đỏ ("sửa sau"). Chỉ cần một lần là chuẩn mực sụp đổ. Bật branch protection yêu cầu CI xanh — để máy làm việc từ chối, không phải con người.
Thực hành#
Trên Dự án 3 (GĐ07/GĐ09):
-
Cài Vitest (hoặc giữ Jest nếu dự án Nest cũ đang dùng Jest) + Testcontainers. Có chốt an toàn chặn
DATABASE_URLkhông phải local.Đáp án
Dùng
vitest.config.tsở mục 3,global-setup.ts,db.tsvàdb-guard.tsở mục 5. Hai chốt thay cho regex dò chuỗi con (regex đó khớp nhầmpostgres://u:p@localhost.evil.com/db):assertTestDatabaseUrl(url)phân tích URL sắp đưa vào adapter của Prisma, bắt buộc host nằm trong danh sách local và tên DB kết thúc bằng_test;assertConnectedToTestDb(prisma)chạySELECT current_database()trên chính kết nối sắpTRUNCATE. Vì URL doglobalSetuptạo từ container (.withDatabase('app_test')) rồiinjectcho từng file,DATABASE_URLcó sẵn trong môi trường của dev (dù trỏ staging) không bao giờ được test dùng. Không áp chốt host lên Docker từ xa theo cách cứng nhắc: với Docker từ xa hoặc Docker-in-Docker,container.getHost()không phảilocalhost; khi đó mở rộng danh sách host cho đúng môi trường CI của bạn, đừng bỏ chốt tên DB. Mong đợi (hàm guard đã chạy, phần container chưa chạy): URL host lạ hoặc tên DBapp_devlàmvitest rundừng ngay ởglobal-setupvới lỗiTừ chối: ..., chưa chạy test nào. -
Viết
test/factories.tschouser,tenant,document.Đáp án
Mẫu ở mục 6; thêm tenant và document:
typescriptReady -
Ma trận authorization đủ mọi kết hợp (role × route × chủ sở hữu tài nguyên). Sau đó cố tình xoá một
@UseGuards()→ xác nhận test đỏ.Đáp án
Dùng bảng
casesở mục 7, thêm hàmresolve(path, doc)thay:idbằng id của tài liệu seed (otherTenanthoặcsameTenant) vàtokenFor(role)ký JWT test. Mutation: xoá@UseGuards(RolesGuard)khỏi route/admin/users. Mong đợi: các dòngMEMBER → 403đỏ với kiểu thông báoexpected 403 "Forbidden", got 200 "OK"; hoàn tác thì xanh. Nếu không đỏ, ma trận chưa kiểm cái bạn nghĩ. -
Integration test chứng minh tenant isolation: xoá
where: { tenantId }khỏi một service → test phải đỏ.Đáp án
Test
KHÔNG trả tài liệu của tenant khácở mục 5. Mutation: bỏtenantIdkhỏiwherecủafindById. Mong đợi: đỏ vìpromise resolved instead of rejecting(service trả tài liệu của B cho A). -
Integration test chứng minh transaction rollback khi bước giữa lỗi.
Đáp án
Test thứ hai ở mục 5;
createJobWithCreditsđặt cảdeductCreditslẫncreateJobtrongprisma.$transaction(async (tx) => {...}). Mutation: gọideductCreditsngoài transaction. Mong đợi:expected 9 to be 10. -
Test idempotency của endpoint có idempotency key: gọi 2 lần → 1 bản ghi.
Đáp án
Endpoint nhận
Idempotency-Key, bảng cóUNIQUE(key):typescriptReadyCơ chế phía server: GĐ09 mục 14. Mong đợi: bỏ ràng buộc
UNIQUEthì test đỏ vớiexpected 2 to be 1(có thể không đỏ mọi lần: race; chạy lặp). -
MSW mock LLM/Stripe với
onUnhandledRequest: 'error'; có test cho JSON hỏng, 429 + retry, timeout.Đáp án
Cấu hình như mục 8; ba đường lỗi:
typescriptReadycộng test JSON hỏng và 429 + retry ở mục 8. Mong đợi: gọi URL chưa khai báo trong test làm test lỗi rõ ràng (
onUnhandledRequest: 'error'). -
Test worker BullMQ ở tầng handler thuần (không dựng Redis).
Đáp án
Hai test ở mục 10 với
depslà fake (không Redis). Mong đợi: lỗi trích text → ném ra vàstatus = FAILED; chạy hai lần → sốchunkkhông đổi. -
Fake timers cho token hết hạn.
Đáp án
Test ở mục 9 (với
toFake: ['Date']vàafterEach(() => vi.useRealTimers())). Lưu ý: fake timers chỉ đổi đồng hồ của Node; nếu hạn token so vớinow()của Postgres thì thời gian không nhúc nhích, nên so với đồng hồ tiêm vào (clock.now()). -
k6: chạy 50 VU trong 2 phút vào endpoint list. Cố tình bỏ một index → chạy lại → quan sát p95 tăng. Thêm lại → xác nhận giảm.
Đáp án
Seed vài trăm nghìn dòng, rồi:
bashReadyMong đợi: lần 2 plan là
Seq Scan, p95 tăng rõ, ngưỡngp(95)<500có thể đỏ; tạo lại index rồi chạy lần 3 thì p95 về gần lần 1. Con số phụ thuộc máy; chỉ so sánh các lần chạy với nhau. -
CI GitHub Actions đủ 5 bước; bật branch protection.
Đáp án
YAML ở mục 14 (không có bước migrate riêng vì
globalSetupđã chạy; cónpx prisma generatesaunpm ci;ubuntu-latestcó Docker cho Testcontainers). Branch protection ở GitHub: Settings → Branches → thêm rule chomain→ Require status checks to pass → chọn jobtest. Mong đợi: PR có test đỏ không bấm Merge được. -
Bật coverage với ngưỡng riêng cho module thanh toán.
Đáp án
Cấu hình ở mục 12 (
include+thresholdsriêng chosrc/billing/**). Mong đợi: nếu dòngsrc/billing/**dưới 95% thìvitest run --coveragein lỗi dạngCoverage for lines (80%) does not meet "src/billing/**" threshold (95%)và thoát mã khác 0. -
Chuyển setup Testcontainers sang
globalSetup+provide/injectvà chứng minh container chỉ bật một lần cho nhiều file test.Đáp án
Dùng
global-setup.tsvàdb.tsở mục 5. Cách chứng minh: thêmconsole.log('[global-setup] bật container')vàosetup, tạo ba file test, chạyvitest run.textReadyMong đợi: một dòng log, và
inject('dbUrl')trong cả ba file trả cùng URL. Đã chạy cơ chế này trên Vitest 5.0.3 với URL giả (không container):globalSetupchạy một lần,setup.tschạy ở mỗi file, teardown chạy một lần. Phần Testcontainers chưa chạy (cần Docker). -
Viết test race cho "đổi mã giảm giá" (hai request cùng lúc chỉ một cái thắng); chứng minh nó đỏ trên bản kiểm-rồi-ghi.
Đáp án
Dùng
AsyncStore,barrier, hai bảnredeemvà test ở mục 10 (phần test race). Quy trình: chép bản sai thànhredeem.ts, chạy, thấy đỏ vớito have a length of 1 but got 2; thay bằng bản đúng, chạy lại thấy xanh. Với DB thật, thayAsyncStore.claimbằngupdateMany({ where: { id, used: false }, data: { used: true } })và kiểmcount === 1. Đã chạy cả hai bản trên fake; chưa chạy trên Postgres. -
Viết property-based test cho
calculateInvoice: ba bất biến, rồi tìm ra phản ví dụ khipercentOffvượt 100.Đáp án
Dùng đoạn mã ở mục 4 (phần property-based). Các bất biến: tổng là số nguyên không âm không vượt tạm tính; thứ tự dòng không đổi kết quả (đoạn mã ở mục 4 chỉ có hai bất biến này; "giảm 0% không đổi tổng" là bất biến thứ ba bạn tự thêm). Nới
percentOfftới 150 để thấy bất biến "không âm" đỏ và nhận phản ví dụ đã thu nhỏ. Sửa bằng validate ở biên vào. Mong đợi (đã chạy): ba bất biến xanh; bản nới miền đỏ,Property failed after N testskèmseed,pathvàCounterexample; con số phản ví dụ phụ thuộc seed của mỗi lần chạy. -
Viết kịch bản k6 theo mô hình mở và đặt ngưỡng trên
dropped_iterations.Đáp án
Dùng script
load/api-open.jsở mục 11 (phần mô hình mở). Chạyk6 run -e BASE_URL=http://localhost:3000 -e TOKEN=... load/api-open.jslần 1 với index, lần 2 sau khi bỏ index (như Bài 10). Mong đợi (chưa chạy, k6 không có trên máy kiểm tra): lần 1dropped_iterationsbằng 0; lần 2 latency tăng,preAllocatedVUscạn, k6 dần tăng VU tớimaxVUsrồi bỏ lượt: ngưỡngdropped_iterationsđỏ. Một kịch bảnstagestheo VU ở cùng tình huống sẽ chỉ thấy tốc độ request tự giảm mà không có chỉ số nào báo bỏ lượt.
Khung và mã dùng chung
Tự làm trước, rồi mở. Toàn bộ là code tham chiếu, chưa chạy (Vitest 5, Prisma 7, MSW 2, k6; không dùng
Docker/Testcontainers trong lúc viết lời giải). Phần "Mong đợi" suy ra từ code và tài liệu, không phải quan sát.
Các mutation (gỡ guard, bỏ tenantId...) làm trên nhánh tạm, rồi hoàn tác.
Lỗi hay gặp: để jsdom; maxWorkers không đặt nên test chạm DB đỏ ngẫu nhiên; quên include nên ngưỡng tính trên tập thiếu;
test authz chỉ kiểm 200 mà không kiểm 401/403/404; retry để che flaky.
Done khi#
-
Giải thích được vì sao kim tự tháp cổ điển không hợp CRUD backend, và vì sao không mock DB.
Đáp án
Giá trị của service CRUD nằm ở tương tác với DB (query, constraint, transaction), nên tầng giữa (integration với DB thật) nặng nhất. Mock DB giấu đúng bug cần bắt (thiếu
where: { tenantId }). Xem mục 2. -
Phân biệt stub / fake / spy / mock; nói được vì sao ưu tiên fake hơn mock.
Đáp án
Stub trả giá trị định sẵn; fake cài đặt thật đơn giản (repo bằng
Map); spy là bản thật có ghi lời gọi; mock khẳng định lời gọi. Ưu tiên fake vì test khẳng định kết quả, không dính cách cài đặt. Chỉ khẳng định lời gọi khi hiệu ứng phụ chính là điều cần kiểm (gửi mail). -
Dựng được Testcontainers chạy migration thật; có chốt chặn DB không phải local.
Đáp án
globalSetupbật container một lần, chạyprisma migrate deployvào đó và cấpdbUrl; mỗi fileinjectURL đó, kiểm bằngassertTestDatabaseUrlvà (trướcTRUNCATE)assertConnectedToTestDb. Tự kiểm:dbUrlcó host lạ hoặc tên DB không đuôi_testphải dừng ngay. Sai thường gặp:db pushthay vì migration thật. -
Chọn và giải thích được chiến lược cô lập (truncate / rollback / schema-per-worker).
Đáp án
Truncate (mặc định an toàn, cần reset sequence), transaction rollback (nhanh nhưng không test được code tự mở transaction), schema/DB mỗi worker (song song thật, setup phức tạp). Với Vitest chạm DB:
maxWorkers: 1hoặc schema riêng cho mỗi worker. -
Có factory; test đọc ra ngay "điều gì đang được kiểm".
Đáp án
Hàm tạo entity hợp lệ, chỉ override phần test quan tâm; đọc
factory.user({ role: 'ADMIN' })là biết test nói về role. Unique dùng bộ đếm, faker thìfaker.seed(...). -
Có ma trận authorization và đã chứng minh nó bắt được lỗi bằng cách gỡ guard.
Đáp án
Bảng role × route × chủ sở hữu, kèm 401 (không token), 403 (đúng role sai quyền), 404 (tenant khác). Tự kiểm: xoá một
@UseGuards()và thấy test đỏ; nếu vẫn xanh thì ma trận thiếu dòng. Xem mục 7. -
Có test chứng minh tenant isolation và transaction rollback.
Đáp án
Isolation: tài liệu của B không lấy được bằng ngữ cảnh A (đỏ khi bỏ
tenantId). Rollback: lỗi sau bước trừ credits thì credits về nguyên giá trị và không còn job. Cả hai chạy trên DB thật. -
Mock external service ở tầng HTTP, có
onUnhandledRequest: 'error', và có test cho đường lỗi (429/timeout/JSON hỏng).Đáp án
MSW chặn request,
onUnhandledRequest: 'error'biến gọi nhầm API thật thành lỗi, và có test cho 429 + retry, timeout, JSON hỏng (không chỉ happy path). -
Không có
sleepcố định trong test; dùng fake timers hoặc polling có timeout.Đáp án
Thời gian: fake timers (
vi.useFakeTimers,setSystemTime); chờ bất đồng bộ: polling có timeout (vi.waitFor/waitFor). Sleep cố định vừa chậm vừa flaky khi CI chậm. -
Test worker ở tầng handler thuần; có test idempotency.
Đáp án
Handler là hàm thuần nhận
payload, deps; test trực tiếp không cần Redis. Test chạy hai lần cùng payload và đếm bản ghi (queue có thể giao trùng); lỗi phải ném ra để BullMQ retry nhưng trạng thái phải được ghi. -
Chạy được k6 và đọc được p95/p99; hiểu vì sao không nhìn trung bình.
Đáp án
Chạy với
stagestăng/giữ/giảm vàthresholdstrênp(95),p(99), tỉ lệ lỗi. Trung bình giấu đuôi chậm mà người dùng cảm nhận; đọc hình dạng đường latency theo tải (tuyến tính = bão hoà, nhảy vọt = hàng đợi đầy). -
TZ=UTCcho test và CI; giải thích được vì sao.Đáp án
Cố định múi giờ để test không phụ thuộc máy chạy (UTC+7 ở local, UTC trên CI); đặt trong script test và
envcủa CI; thêm test riêng với múi giờ khác cho logic ngày. -
Coverage có gate hợp lý, siết riêng vùng nhạy cảm; nói được vì sao coverage không phải KPI.
Đáp án
Gate thấp và không giảm (ví dụ 70% lines), ngưỡng riêng cho vùng tiền (
src/billing/**), ưu tiên branch coverage, cócoverage.include. Coverage đo code đã chạy, không đo hành vi đã khẳng định nên không phải KPI. -
CI chạy lint → typecheck → test theo thứ tự chi phí; branch protection bật.
Đáp án
lint → typecheck → (migrate) → test; bước rẻ chạy trước. Bật "Require status checks" trên nhánh chính để máy từ chối merge khi đỏ. Tự kiểm: tạo PR cố tình lỗi, nút Merge bị khoá.
-
Không dùng
retryđể che flaky; biết 6 nguyên nhân flaky và cách trị từng cái.Đáp án
Rò trạng thái, thời gian thật, thứ tự không xác định (thiếu
ORDER BY), dữ liệu random, tài nguyên tranh chấp (cổng/DB dùng chung), timezone/locale. Trị: truncate sau mỗi test, fake timers,orderBy, seed, port 0/container,TZ=UTC. Xem bảng ở mục 13. -
Setup DB test bằng
globalSetup+provide/inject; giải thích vì sao không bật container trongbeforeAllcủasetupFilesĐáp án
setupFileschạy ở mỗi file test nênbeforeAllbật một container và chạy migration cho từng file.globalSetupchạy một lần, cấpdbUrlbằngproject.provide, mỗi file lấy bằnginject('dbUrl'), và hàm trả về là teardown dừng container. Tự kiểm: ba file test, log trongglobalSetupchỉ in một lần. -
Có chốt chặn hai lớp trước khi TRUNCATE: phân tích URL đưa vào Prisma và
current_database()trên chính kết nốiĐáp án
Lớp 1:
new URL(url), host nằm trong danh sách và tên DB kết thúc_test. Lớp 2:SELECT current_database()trên kết nối sắp truncate. Regex dò chuỗi con khớp nhầmlocalhost.evil.com;localhostcòn có thể là DB dev. Tự kiểm: URLpostgres://u:p@localhost:5432/app_devbị từ chối. -
Có test race (Promise.all hoặc barrier) và đã chứng minh nó đỏ trên code kiểm-rồi-ghi
Đáp án
Hai request chồng nhau ở khe giữa đọc và ghi bằng barrier; khẳng định đúng một request thành công. Chạy trên bản kiểm-rồi-ghi thấy đỏ (
got 2), đổi sang bản nguyên tử (UPDATE ... WHERE used = false, kiểm số dòng) thấy xanh. Nếu test xanh trên bản sai thì test chưa ép được chồng nhau. -
Viết được property-based test, đọc được phản ví dụ đã thu nhỏ, biết ghim nó thành test thường
Đáp án
Khai báo bất biến (không âm, không vượt tạm tính, bất biến theo thứ tự) và để
fast-checksinh đầu vào; khi đỏ đọcCounterexample, ghiseed, rồi thêm một test ví dụ dùng đúng phản ví dụ đó. Miền sinh phải khớp miền hợp lệ của hàm. -
Phân biệt mô hình đóng và mô hình mở của k6; nói được vì sao
dropped_iterationsquan trọngĐáp án
Mô hình đóng (
stagestheo VU) chờ phản hồi nên tốc độ request giảm khi server chậm (coordinated omission). Mô hình mở (constant-arrival-rate,ramping-arrival-rate) giữ tốc độ đến cố định; hết VU rảnh thì k6 bỏ lượt và tăngdropped_iterations, dấu hiệu server không kịp nhận tải. Đặt ngưỡngcount==0để việc bỏ lượt làm bài đỏ.
Câu hỏi mở / chưa giải quyết#
-
Testcontainers khởi động chậm (~10–20s lần đầu). Cân nhắc
services:của GitHub Actions cho CI và Testcontainers cho local — đánh đổi giữa tốc độ và tính giống nhau giữa hai môi trường.Hướng trả lời hiện tại (chưa chốt)
Dùng
services:của GitHub Actions cho CI và Testcontainers cho local là hợp lý nếu hai bên cùng image và cùng phiên bản Postgres; chấp nhận khác biệt nhỏ để CI nhanh hơn. -
Contract test (Pact) chỉ đáng khi có nhiều consumer độc lập của API. Với một sản phẩm một team, OpenAPI schema + E2E là đủ — xem lại khi tách service.
Hướng trả lời hiện tại (chưa chốt)
Chưa cần với một team một sản phẩm; khi tách service thì đánh giá lại.
-
Mutation testing (Stryker) đo chất lượng test tốt hơn coverage rất nhiều, nhưng chậm. Đáng chạy hàng tuần cho riêng module thanh toán.
Hướng trả lời hiện tại (chưa chốt)
Thử trước trên module thanh toán, chạy theo lịch thay vì mỗi PR.
-
Test cho code LLM (GĐ22): output không tất định nên không khẳng định được nội dung. Hướng đi là eval (chấm điểm theo tiêu chí, có ngưỡng) chứ không phải assert — đào sâu ở GĐ22 mục 11.
Hướng trả lời hiện tại (chưa chốt)
Dùng eval thay vì assert; xem GĐ22.