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 globalSetup với provide/inject, guard URL, truncate động, test race, property-based và fake timers, kết quả ghi ở từng mục; tsc --strict sạ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ỉ qua node --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.

FEBE
Trạng tháiTrong bộ nhớ, mất khi refreshBền vững trong DB — test này làm bẩn test kia
Đồng thời1 người dùng / 1 tabHàng nghìn request song song → race condition, deadlock
Hậu quả saiNút lệch chỗTrừ tiền hai lần, user A đọc dữ liệu user B
Phụ thuộc ngoàiAPI (mock được dễ)DB, Redis, S3, Stripe, LLM — nhiều và có trạng thái
Người phát hiện lỗiNgười dùng thấy ngayCó 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ự:

  1. 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.
  2. Tính toán tiền/quota — làm tròn, tiền tệ, hạn mức.
  3. Ranh giới transaction — lỗi giữa chừng có rollback đúng không.
  4. Idempotency — gọi 2 lần có tạo 2 bản ghi không.
  5. 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:

textReady
        /  E2E API   \      ~10%  — luồng quan trọng: signup, thanh toán, authz      /  Integration  \     ~60%  — service + DB thật. ĐÂY LÀ TRỌNG TÂM.    /      Unit         \   ~30%  — logic thuần: tính giá, parse, state machine

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:

VitestJest
ESMNative, không cần cấu hìnhCần --experimental-vm-modules, hay vướng
TypeScriptSẵn (esbuild)Cần ts-jest (chậm) hoặc @swc/jest
Tốc độNhanh hơn rõ rệtChậm hơn
NestJSMặc định của project mới từ Nest 12Mặc định của Nest cũ (trước 12); dự án đang dùng thì giữ
APIGầ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ùngDùngVì sao
Express / Fastify / Node thuần, ESMVitestESM + TS chạy ngay, nhanh hơn rõ rệt, không có tầng transform để hỏng
NestJS 12Vitest (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 đủ.

jsonReady
// jest.config.json — đổi ts-jest → @swc/jest{ "transform": { "^.+\\.(t|j)s$": "@swc/jest" } }

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:

typescriptReady
import { defineConfig } from 'vitest/config';export default defineConfig({  test: {    environment: 'node',            // KHÔNG phải jsdom — backend không có DOM    globals: true,    globalSetup: ['./test/global-setup.ts'],   // chạy MỘT lần cho cả lần chạy test (mục 5)    setupFiles: ['./test/setup.ts'],           // chạy ở MỖI file test    // Test chạm DB không được chạy song song trên cùng một database.    // Hoặc chỉ cho 1 worker chạy, hoặc cấp mỗi worker một schema riêng (mục 5).    maxWorkers: 1,    testTimeout: 15_000,            // container DB khởi động chậm ở lần đầu    coverage: {      provider: 'v8',      reporter: ['text', 'lcov'],      include: ['src/**/*.ts'],     // Vitest 4+: không khai thì file không được import sẽ vắng mặt trong báo cáo    },  },});

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. (findById luô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:

typescriptReady
// src/billing/pricing.ts — không chạm DB, không chạm mạng → unit test thuần tuýexport function calculateInvoice(items: Item[], coupon?: Coupon): Money {  // Tiền LUÔN tính bằng số nguyên đơn vị nhỏ nhất (cent/đồng). Float là bug.  const subtotal = items.reduce((s, i) => s + i.unitPriceCents * i.qty, 0);  const discount = coupon ? Math.floor(subtotal * coupon.percentOff / 100) : 0;  return { cents: subtotal - discount, currency: 'VND' };}
typescriptReady
describe('calculateInvoice', () => {  it('cộng dồn theo số lượng', () => {    expect(calculateInvoice([{ unitPriceCents: 1000, qty: 3 }]).cents).toBe(3000);  });  // Giá trị biên là nơi bug sống. Test chúng trước khi test happy path.  it('làm tròn xuống khi giảm giá lẻ', () => {    // 999 * 10% = 99.9 → phải là 99, không phải 100 (đừng để khách hàng bị lợi/thiệt 1 đồng)    expect(calculateInvoice([{ unitPriceCents: 999, qty: 1 }], { percentOff: 10 }).cents).toBe(900);  });  it('giỏ rỗng trả 0, không NaN', () => {    expect(calculateInvoice([]).cents).toBe(0);  });});

Ví dụ — fake tốt hơn mock:

typescriptReady
// ✅ Fake: hành xử như thật, test đọc dễ, không ràng buộc cài đặtclass InMemoryUserRepo implements UserRepo {  private rows = new Map<string, User>();  async save(u: User) { this.rows.set(u.id, u); }  async findByEmail(e: string) { return [...this.rows.values()].find(u => u.email === e) ?? null; }}it('không cho đăng ký trùng email', async () => {  const repo = new InMemoryUserRepo();  const svc = new SignupService(repo, new NoopEmailPort());  await svc.signup({ email: 'a@x.com', password: 'Str0ng!Pass' });  await expect(svc.signup({ email: 'a@x.com', password: 'Other!Pass1' }))    .rejects.toThrow(ConflictError);          // khẳng định HÀNH VI, không phải lời gọi});

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.

typescriptReady
import fc from 'fast-check'const item = fc.record({ unitPriceCents: fc.integer({ min: 0, max: 10_000_000 }), qty: fc.integer({ min: 0, max: 100 }) })const subtotalOf = (xs: Item[]) => xs.reduce((s, i) => s + i.unitPriceCents * i.qty, 0)it('tổng là số nguyên, không âm, không vượt tạm tính', () => {  fc.assert(fc.property(    fc.array(item),    fc.option(fc.record({ percentOff: fc.integer({ min: 0, max: 100 }) }), { nil: undefined }),    (items, coupon) => {      const { cents } = calculateInvoice(items, coupon)      return Number.isInteger(cents) && cents >= 0 && cents <= subtotalOf(items)    }))})it('thứ tự các dòng không đổi kết quả', () => {  fc.assert(fc.property(fc.array(item), fc.integer({ min: 0, max: 100 }), (items, percentOff) =>    calculateInvoice(items, { percentOff }).cents === calculateInvoice([...items].reverse(), { percentOff }).cents))})

Đ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):

textReady
Property failed after 2 testsCounterexample: [[{"unitPriceCents":1,"qty":3}],134]Shrunk 33 time(s)

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:

typescriptReady
// test/global-setup.ts — chạy MỘT lần, trong tiến trình chính của Vitestimport type { TestProject } from 'vitest/node'import { PostgreSqlContainer } from '@testcontainers/postgresql'import { execSync } from 'node:child_process'import { assertTestDatabaseUrl } from './db-guard.ts'declare module 'vitest' {  export interface ProvidedContext { dbUrl: string }}export default async function setup(project: TestProject) {  const container = await new PostgreSqlContainer('postgres:18-alpine')    .withDatabase('app_test')               // tên kết thúc bằng _test: guard dựa vào đây    .start()  const url = container.getConnectionUri()  assertTestDatabaseUrl(url)  // Đặt cả MIGRATE_DATABASE_URL: nếu prisma.config.ts đọc biến này (GĐ14) thì CLI mới migrate vào container  execSync('npx prisma migrate deploy', { env: { ...process.env, DATABASE_URL: url, MIGRATE_DATABASE_URL: url }, stdio: 'inherit' })  project.provide('dbUrl', url)  return async () => { await container.stop() }}

Đặ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ệ).

typescriptReady
// test/db.ts — dùng trong test và factories; chạy ở mỗi fileimport { inject } from 'vitest'import { PrismaPg } from '@prisma/adapter-pg'import { PrismaClient } from '../src/generated/prisma/client'   // đường dẫn output do schema.prisma của bạn quy định (Prisma 7)import { assertTestDatabaseUrl } from './db-guard.ts'const url = inject('dbUrl')            // URL duy nhất test được phép dùngassertTestDatabaseUrl(url)             // kiểm đúng chuỗi sắp đưa vào adapterexport const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) })

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ượcCách làmTốc độNhược
TruncateTRUNCATE ... CASCADE sau mỗi testTrung bìnhPhải liệt kê bảng; reset sequence
Transaction rollbackMở transaction trước test, rollback sauNhanh nhấtKhông test được code tự mở transaction (nested)
Schema/DB per workerMỗi worker một schema riêngNhanh, song song thậtSetup phức tạp hơn

Ví dụ — truncate (mặc định an toàn, khuyên dùng để bắt đầu):

typescriptReady
afterEach(async () => {  await assertConnectedToTestDb(prisma);        // chốt cuối, xem Pitfall bên dưới  // Lấy danh sách bảng động → thêm bảng mới không phải sửa file test  const tables = await prisma.$queryRaw<{ tablename: string }[]>`    SELECT tablename FROM pg_tables    WHERE schemaname='public' AND tablename NOT LIKE '_prisma%'`;  const list = tables.map(t => `"public"."${t.tablename}"`).join(', ');  await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE`);});

Ví dụ — test thật sự đáng giá (authorization + tenant isolation):

typescriptReady
describe('DocumentService.findById', () => {  it('KHÔNG trả tài liệu của tenant khác', async () => {    const a = await factory.tenant();    const b = await factory.tenant();    const docOfB = await factory.document({ tenantId: b.id });    // Đây là bug bảo mật kinh điển: service quên `where: { tenantId }`.    // Chỉ integration test với DB thật mới bắt được.    await expect(svc.findById(docOfB.id, { tenantId: a.id })).rejects.toThrow(NotFoundError);  });  it('rollback toàn bộ khi bước giữa lỗi', async () => {    const user = await factory.user({ credits: 10 });    // deductCredits thành công rồi createJob ném lỗi → credits PHẢI về 10    await expect(svc.createJobWithCredits(user.id, { forceFailAfterDeduct: true })).rejects.toThrow();    const after = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });    expect(after.credits).toBe(10);            // nếu là 9 → transaction đặt sai chỗ    expect(await prisma.job.count()).toBe(0);  });});

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:

typescriptReady
// test/db-guard.tsconst LOCAL_HOSTS = ['localhost', '127.0.0.1', '[::1]']// Kiểm URL mà Prisma sẽ THỰC SỰ dùng (chuỗi đưa vào adapter), không dò chuỗi con trong cả URL.export function assertTestDatabaseUrl(url: string) {  const u = new URL(url)  const name = decodeURIComponent(u.pathname.slice(1))  if (!LOCAL_HOSTS.includes(u.hostname) || !name.endsWith('_test'))    throw new Error(`Từ chối: ${u.hostname}/${name} không phải DB test local (tên DB phải kết thúc bằng _test)`)}type Sql = { $queryRaw<T>(s: TemplateStringsArray): Promise<T> }// Chốt cuối, chạy trên CHÍNH kết nối sắp TRUNCATE: hỏi server đang nối vào DB nào.export async function assertConnectedToTestDb(prisma: Sql) {  const [row] = await prisma.$queryRaw<{ current_database: string }[]>`SELECT current_database()`  if (!row?.current_database.endsWith('_test'))    throw new Error(`Từ chối TRUNCATE: đang nối vào "${row?.current_database}"`)}

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ụ:

typescriptReady
// test/factories.tslet seq = 0;   // đếm tăng dần — KHÔNG dùng random cho giá trị unique (mục pitfall)export const factory = {  async user(over: Partial<User> = {}) {    seq++;    return prisma.user.create({      data: {        email: `user${seq}@test.local`,        passwordHash: await hash('Test1234!'),        role: 'MEMBER',        credits: 100,        ...over,                              // chỉ ghi đè cái test quan tâm      },    });  },  async document(over: Partial<Document> & { tenantId: string }) { /* ... */ },};
typescriptReady
// Đọc phát hiểu ngay: test này nói về role, mọi thứ khác không liên quan.const admin = await factory.user({ role: 'ADMIN' });

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):

typescriptReady
describe('Authorization matrix', () => {  const cases = [    { route: 'GET  /admin/users',    role: 'MEMBER', expect: 403 },    { route: 'GET  /admin/users',    role: 'ADMIN',  expect: 200 },    { route: 'GET  /admin/users',    role: null,     expect: 401 },   // không token    // Tài nguyên của tenant KHÁC: 404, không phải 403 — đừng lộ id nào có thật    { route: 'DELETE /documents/:id', role: 'MEMBER', expect: 404, doc: 'otherTenant' },    // Cùng tenant, doc có thật nhưng MEMBER không được xoá: tồn tại là công khai → 403    { route: 'DELETE /documents/:id', role: 'MEMBER', expect: 403, doc: 'sameTenant' },  ];  it.each(cases)('$route với role=$role → $expect', async ({ route, role, expect: code, doc }) => {    const [method, path] = route.split(/\s+/);    const req = request(app.getHttpServer())[method.toLowerCase()](resolve(path, doc));  // doc: seed sẵn theo tenant    if (role) req.set('Authorization', `Bearer ${await tokenFor(role)}`);    await req.expect(code);  });});

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.

typescriptReady
// Test luồng nghiệp vụ đầy đủ — ít thôi, nhưng phải cóit('signup → verify → login', async () => {  await request(app).post('/auth/signup').send({ email: 'a@x.com', password: 'Str0ng!Pass' }).expect(201);  // Lấy token từ fake email adapter, KHÔNG query thẳng DB — như vậy test cả  // việc mail có thực sự được gửi kèm token hay không.  const token = fakeEmail.lastMessageTo('a@x.com')!.extractToken();  await request(app).post('/auth/verify').send({ token }).expect(200);  const res = await request(app).post('/auth/login').send({ email: 'a@x.com', password: 'Str0ng!Pass' }).expect(200);  expect(res.body.accessToken).toBeDefined();  expect(res.body.passwordHash).toBeUndefined();   // đừng bao giờ rò field nhạy cảm});

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):

typescriptReady
import { setupServer } from 'msw/node';import { http, HttpResponse } from 'msw';const server = setupServer(  http.post('https://api.openai.com/v1/chat/completions', () =>    HttpResponse.json({ choices: [{ message: { content: '{"summary":"ok"}' } }], usage: { total_tokens: 42 } })),);beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));  // gọi mạng ngoài dự kiến → FAIL ngayafterEach(() => server.resetHandlers());afterAll(() => server.close());it('xử lý được khi LLM trả JSON hỏng', async () => {  server.use(http.post('https://api.openai.com/v1/chat/completions', () =>    HttpResponse.json({ choices: [{ message: { content: 'Chào bạn! {broken' } }] })));  // LLM thường xuyên trả JSON sai — code PHẢI chịu được, không được crash 500  await expect(svc.summarize('text')).rejects.toThrow(InvalidLLMOutputError);});it('retry khi bị 429', async () => {  let n = 0;  server.use(http.post('https://api.openai.com/v1/chat/completions', () =>    ++n === 1 ? new HttpResponse(null, { status: 429 }) : HttpResponse.json(okBody)));  await svc.summarize('text');  expect(n).toBe(2);});

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ụ:

typescriptReady
// ❌ Không test được "token hết hạn" mà không chờ 30 phút thậtfunction isExpired(t: Token) { return t.expiresAt < new Date(); }// ✅ Fake timers: chỉ giả Date khi bạn chỉ cần đồng hồimport { vi, afterEach } from 'vitest';afterEach(() => { vi.useRealTimers(); });          // chạy cả khi test đỏit('token hết hạn sau 30 phút', async () => {  vi.useFakeTimers({ toFake: ['Date'] });          // setTimeout của driver DB vẫn chạy thật  vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));  const t = await svc.createResetToken(user.id);  vi.setSystemTime(new Date('2026-01-01T00:31:00Z'));  await expect(svc.consumeResetToken(t)).rejects.toThrow(TokenExpiredError);});

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òng vi.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. Code await mộ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ùng toFake: ['Date'].
  • Khi buộc phải giả timer, dùng advanceTimersByTimeAsync. Bản đồng bộ advanceTimersByTime chạy timer nhưng không xả microtask giữa các timer, nên chuỗi await phí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:

jsonReady
// package.json — hoặc set TZ=UTC trong CI env{ "scripts": { "test": "TZ=UTC vitest run" } }

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:

typescriptReady
// ✅ Handler là hàm thuần nhận payload → unit/integration test trực tiếp,//    không cần dựng Redis, không cần chờ queue.export async function handleProcessDocument(payload: { fileId: string }, deps: Deps) { /* ... */ }// Đăng ký với BullMQ chỉ là 1 dòng mỏng, gần như không cần testnew Worker('documents', job => handleProcessDocument(job.data, deps));
typescriptReady
it('đánh dấu FAILED và không nuốt lỗi khi trích text hỏng', async () => {  const file = await factory.file({ status: 'READY' });  extractorStub.rejectWith(new CorruptPdfError());  await expect(handleProcessDocument({ fileId: file.id }, deps)).rejects.toThrow(CorruptPdfError);  // Ném lỗi ra ngoài là ĐÚNG — BullMQ cần thấy lỗi để retry.  // Nhưng trạng thái phải được ghi lại để người dùng biết.  expect((await reload(file)).status).toBe('FAILED');});it('idempotent: chạy 2 lần không tạo 2 bản ghi', async () => {  await handleProcessDocument({ fileId: file.id }, deps);  await handleProcessDocument({ fileId: file.id }, deps);   // queue CÓ THỂ giao trùng  expect(await prisma.chunk.count({ where: { fileId: file.id } })).toBe(EXPECTED);});

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.

typescriptReady
// test/async-store.ts — fake bất đồng bộ của bảng coupons (thay DB trong test race)export type Coupon = { id: string; used: boolean }// Cổng chặn: chỉ thả khi đủ `parties` bên đã tới, hoặc sau `waitMs` (code tuần tự hoá bằng khoá vẫn không treo)export function barrier(parties: number, waitMs = 100) {  let arrived = 0  let release!: () => void  const open = new Promise<void>((r) => { release = r })  return async () => {    if (++arrived >= parties) release()    await Promise.race([open, new Promise((r) => setTimeout(r, waitMs))])  }}export class AsyncStore {  rows = new Map<string, Coupon>()  gate: (() => Promise<void>) | null = null           // móc đặt ở điểm "giữa đọc và ghi"  async get(id: string) {    const row = this.rows.get(id)    const copy = row ? { ...row } : undefined          // trả bản sao như một lần SELECT    if (this.gate) await this.gate()    return copy  }  async set(id: string, row: Coupon) { this.rows.set(id, { ...row }) }  // Một bước nguyên tử, tương đương UPDATE coupons SET used = true WHERE id = $1 AND used = false  async claim(id: string) {    const row = this.rows.get(id)    if (!row || row.used) return false    row.used = true    return true  }}
typescriptReady
// test/redeem.bad.ts — SAI: kiểm rồi mới ghi; hai request cùng đọc used=false thì cả hai quaexport async function redeem(store: AsyncStore, id: string, grant: () => void) {  const c = await store.get(id)  if (!c || c.used) throw new Error('coupon không dùng được')  await store.set(id, { ...c, used: true })  grant()}
typescriptReady
// test/redeem.good.ts — ĐÚNG: một bước nguyên tử quyết định ai thắngexport async function redeem(store: AsyncStore, id: string, grant: () => void) {  if (!(await store.claim(id))) throw new Error('coupon không dùng được')  grant()}
typescriptReady
// test/redeem.test.tsimport { it, expect } from 'vitest'import { AsyncStore, barrier } from './async-store.ts'import { redeem } from './redeem.ts'it('hai request cùng lúc: đúng một cái thành công và chỉ cấp một lần', async () => {  const store = new AsyncStore()  store.rows.set('c1', { id: 'c1', used: false })  store.gate = barrier(2)                       // ép cả hai cùng ở giữa đọc và ghi  let granted = 0  const results = await Promise.allSettled([    redeem(store, 'c1', () => granted++),    redeem(store, 'c1', () => granted++),  ])  expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)  expect(granted).toBe(1)})

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ử claim chỉ 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ới prisma.coupon.updateMany({ where: { id, used: false }, data: { used: true } }) rồi kiểm count === 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ụ:

typescriptReady
// load/api.jsimport http from 'k6/http';import { check } from 'k6';export const options = {  stages: [    { duration: '30s', target: 50 },    // tăng dần    { duration: '2m',  target: 50 },    // giữ tải — giai đoạn quan trọng nhất    { duration: '30s', target: 0 },  ],  thresholds: {    // p95 chứ KHÔNG phải trung bình. Trung bình giấu đi đuôi chậm mà người dùng thực sự cảm nhận.    http_req_duration: ['p(95)<500', 'p(99)<1500'],    http_req_failed: ['rate<0.01'],  },};export default function () {  const res = http.get(`${__ENV.BASE_URL}/documents?limit=20`, {    headers: { Authorization: `Bearer ${__ENV.TOKEN}` },  });  check(res, { 'status 200': r => r.status === 200 });}

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.

typescriptReady
// load/api-open.jsimport http from 'k6/http'import { check } from 'k6'export const options = {  scenarios: {    steady: {      executor: 'constant-arrival-rate',      rate: 50, timeUnit: '1s',          // 50 request MỚI mỗi giây, bất kể server trả lời nhanh hay chậm      duration: '2m',      preAllocatedVUs: 20,               // dự trù theo rate x latency dự kiến      maxVUs: 200,                       // trần để máy bắn tải không tự kiệt sức    },  },  thresholds: {    http_req_duration: ['p(95)<500', 'p(99)<1500'],    http_req_failed: ['rate<0.01'],    // Hết VU rảnh thì k6 bỏ lượt: server chậm đến mức không kịp nhận tải. Phải đỏ chứ không được lặng lẽ qua.    dropped_iterations: ['count==0'],  },}export default function () {  const res = http.get(`${__ENV.BASE_URL}/documents?limit=20`, {    headers: { Authorization: `Bearer ${__ENV.TOKEN}` },  })  check(res, { 'status 200': (r) => r.status === 200 })}
  • preAllocatedVUs dự trù theo rate x latency dự kiến; maxVUs là trần của máy bắn tải. Hết VU rảnh thì k6 bỏ lượt và tăng bộ đếm dropped_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ếu preAllocatedVUs; nếu xảy ra giữa chừng thì thường do server đang suy giảm.
  • Dùng ramping-arrival-rate (với startRate, stages mục tiêu theo số request mỗi timeUnit) 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 else không chạy mới là chỗ ẩn bug.
jsonReady
// vitest coverage với ngưỡng riêng cho vùng nhạy cảm{  "coverage": {    "include": ["src/**/*.ts"],   // thiếu dòng này, ngưỡng chỉ tính trên file đã được import    "thresholds": {      "lines": 70, "branches": 65,      "src/billing/**": { "lines": 95, "branches": 90 }   // tiền bạc thì siết chặt    },    "exclude": ["**/*.dto.ts", "**/*.module.ts", "src/main.ts"]  }}

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ânDấu hiệuCách trị
Rò rỉ trạng thái giữa testĐỏ khi đổi thứ tự / chạy song songTruncate sau mỗi test; đừng dùng biến module-level
Phụ thuộc thời gian thậtsleep, so sánh Date.now()Fake timers; polling thay vì sleep
Thứ tự không xác địnhfindMany không có ORDER BYLuôn orderBy khi khẳng định thứ tự
Dữ liệu randomfaker không seedSeed cố định
Cổng/tài nguyên tranh chấpĐỏ khi chạy song songPort 0 (OS tự cấp); Testcontainers
Timezone/localeĐỏ trên CI, xanh ở localTZ=UTC cho cả test lẫn CI

Ví dụ — bẫy thứ tự:

typescriptReady
// ❌ Postgres KHÔNG đảm bảo thứ tự nếu không có ORDER BY. Đúng 99 lần, sai lần thứ 100.expect(result[0].name).toBe('An');// ✅expect(result.map(r => r.name)).toEqual(expect.arrayContaining(['An', 'Bình']));// hoặc ép thứ tự ngay trong query của service

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:

textReady
name: cion: [push, pull_request]jobs:  test:    runs-on: ubuntu-latest    env: { TZ: UTC }    steps:      - uses: actions/checkout@v7      - uses: actions/setup-node@v7        with: { node-version: 24, cache: 'npm' }   # Node 24 LTS; hoặc node-version-file: '.nvmrc'      - run: npm ci                     # ci, KHÔNG phải install (GĐ02 mục 8)      - run: npx prisma generate        # Prisma 7 không tự generate; typecheck và test cần client đã sinh      # Chạy các bước rẻ TRƯỚC — fail sớm, tiết kiệm thời gian chờ      - run: npm run lint      - run: npm run typecheck          # bắt buộc: build bằng esbuild không type-check      - run: npm run test -- --coverage      # Không có bước migrate riêng: globalSetup đã chạy migrate deploy vào container Testcontainers      # (cần Docker, có sẵn trên ubuntu-latest). Một DATABASE_URL giả ở CI chỉ làm bước migrate      # chạy vào một DB không tồn tại, và không phải DB mà test dùng.

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):

  1. 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_URL không phải local.

    Đáp án

    Dùng vitest.config.ts ở mục 3, global-setup.ts, db.ts và db-guard.ts ở mục 5. Hai chốt thay cho regex dò chuỗi con (regex đó khớp nhầm postgres://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ạy SELECT current_database() trên chính kết nối sắp TRUNCATE. Vì URL do globalSetup tạo từ container (.withDatabase('app_test')) rồi inject cho từng file, DATABASE_URL có 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ải localhost; 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 DB app_dev làm vitest run dừng ngay ở global-setup với lỗi Từ chối: ..., chưa chạy test nào.

  2. Viết test/factories.ts cho user, tenant, document.

    Đáp án

    Mẫu ở mục 6; thêm tenant và document:

    typescriptReady
    async tenant(over = {}) { seq++; return prisma.tenant.create({ data: { name: `t${seq}`, ...over } }) },async document(over: { tenantId: string } & Partial<Document>) {  seq++; return prisma.document.create({ data: { title: `doc${seq}`, ...over } })},
  3. 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àm resolve(path, doc) thay :id bằng id của tài liệu seed (otherTenant hoặc sameTenant) và tokenFor(role) ký JWT test. Mutation: xoá @UseGuards(RolesGuard) khỏi route /admin/users. Mong đợi: các dòng MEMBER → 403 đỏ với kiểu thông báo expected 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ĩ.

  4. 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ỏ tenantId khỏi where của findById. Mong đợi: đỏ vì promise resolved instead of rejecting (service trả tài liệu của B cho A).

  5. 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ả deductCredits lẫn createJob trong prisma.$transaction(async (tx) => {...}). Mutation: gọi deductCredits ngoài transaction. Mong đợi: expected 9 to be 10.

  6. 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):

    typescriptReady
    it('gọi 2 lần cùng key → 1 bản ghi', async () => {  const send = () => request(app).post('/orders').set('Idempotency-Key', 'k1').send(body)  const [a, b] = await Promise.all([send(), send()])          // cả trường hợp song song  expect([a.status, b.status].every((s) => s === 201 || s === 200 || s === 409)).toBe(true)  expect(await prisma.order.count()).toBe(1)})

    Cơ chế phía server: GĐ09 mục 14. Mong đợi: bỏ ràng buộc UNIQUE thì test đỏ với expected 2 to be 1 (có thể không đỏ mọi lần: race; chạy lặp).

  7. 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:

    typescriptReady
    import { http, HttpResponse, delay } from 'msw'it('timeout', async () => {  server.use(http.post(URL, async () => { await delay('infinite') }))  await expect(svc.summarize('x')).rejects.toThrow()   // svc dùng fetch(..., { signal: AbortSignal.timeout(200) })})

    cộ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').

  8. Test worker BullMQ ở tầng handler thuần (không dựng Redis).

    Đáp án

    Hai test ở mục 10 với deps là fake (không Redis). Mong đợi: lỗi trích text → ném ra và status = FAILED; chạy hai lần → số chunk không đổi.

  9. 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ới now() 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()).

  10. 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:

    bashReady
    psql "$TEST_DB" -c "EXPLAIN (ANALYZE) SELECT * FROM documents WHERE tenant_id = '...' ORDER BY created_at DESC LIMIT 20"k6 run -e BASE_URL=http://localhost:3000 -e TOKEN=... load/api.js     # lần 1: có indexpsql "$TEST_DB" -c "DROP INDEX documents_tenant_id_created_at_idx"      # chỉ trên DB testk6 run ...                                                              # lần 2: không index

    Mong đợi: lần 2 plan là Seq Scan, p95 tăng rõ, ngưỡng p(95)<500 có 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.

  11. 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 generate sau npm ci; ubuntu-latest có Docker cho Testcontainers). Branch protection ở GitHub: Settings → Branches → thêm rule cho main → Require status checks to pass → chọn job test. Mong đợi: PR có test đỏ không bấm Merge được.

  12. Bật coverage với ngưỡng riêng cho module thanh toán.

    Đáp án

    Cấu hình ở mục 12 (include + thresholds riêng cho src/billing/**). Mong đợi: nếu dòng src/billing/** dưới 95% thì vitest run --coverage in lỗi dạng Coverage for lines (80%) does not meet "src/billing/**" threshold (95%) và thoát mã khác 0.

  13. Chuyển setup Testcontainers sang globalSetup + provide/inject và chứng minh container chỉ bật một lần cho nhiều file test.

    Đáp án

    Dùng global-setup.ts và db.ts ở mục 5. Cách chứng minh: thêm console.log('[global-setup] bật container') vào setup, tạo ba file test, chạy vitest run.

    textReady
    [global-setup] bật container      <- đúng một dòng, dù có 3 file test

    Mong đợ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): globalSetup chạy một lần, setup.ts chạy ở mỗi file, teardown chạy một lần. Phần Testcontainers chưa chạy (cần Docker).

  14. 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ản redeem và test ở mục 10 (phần test race). Quy trình: chép bản sai thành redeem.ts, chạy, thấy đỏ với to 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, thay AsyncStore.claim bằng updateMany({ where: { id, used: false }, data: { used: true } }) và kiểm count === 1. Đã chạy cả hai bản trên fake; chưa chạy trên Postgres.

  15. Viết property-based test cho calculateInvoice: ba bất biến, rồi tìm ra phản ví dụ khi percentOff vượ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 percentOff tớ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 tests kèm seed, path và Counterexample; con số phản ví dụ phụ thuộc seed của mỗi lần chạy.

  16. 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ạy k6 run -e BASE_URL=http://localhost:3000 -e TOKEN=... load/api-open.js lầ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 1 dropped_iterations bằng 0; lần 2 latency tăng, preAllocatedVUs cạn, k6 dần tăng VU tới maxVUs rồi bỏ lượt: ngưỡng dropped_iterations đỏ. Một kịch bản stages theo 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.

textReady
 global-setup ─ container ─ migrate deploy ─ provide(dbUrl) ┐ test/db.ts ─ inject(dbUrl) ─ guard URL + current_database ─┤ factories ─► integration (service + DB thật) ──────────────►├─► vitest run supertest ─► authz matrix, luồng chính ────────────────────┘   (maxWorkers: 1) MSW (chặn HTTP ngoài, onUnhandledRequest: 'error')               │ CI: generate → lint → typecheck → test + coverage ─► branch protection

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

    globalSetup bật container một lần, chạy prisma migrate deploy vào đó và cấp dbUrl; mỗi file inject URL đó, kiểm bằng assertTestDatabaseUrl và (trước TRUNCATE) assertConnectedToTestDb. Tự kiểm: dbUrl có host lạ hoặc tên DB không đuôi _test phải dừng ngay. Sai thường gặp: db push thay 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: 1 hoặ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ó sleep cố đị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 stages tăng/giữ/giảm và thresholds trên p(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=UTC cho 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à env củ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 trong beforeAll của setupFiles

    Đáp án

    setupFiles chạy ở mỗi file test nên beforeAll bật một container và chạy migration cho từng file. globalSetup chạy một lần, cấp dbUrl bằng project.provide, mỗi file lấy bằng inject('dbUrl'), và hàm trả về là teardown dừng container. Tự kiểm: ba file test, log trong globalSetup chỉ 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ầm localhost.evil.com; localhost còn có thể là DB dev. Tự kiểm: URL postgres://u:p@localhost:5432/app_dev bị 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-check sinh đầu vào; khi đỏ đọc Counterexample, ghi seed, 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_iterations quan trọng

    Đáp án

    Mô hình đóng (stages theo 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ăng dropped_iterations, dấu hiệu server không kịp nhận tải. Đặt ngưỡng count==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.