HARRY-15 — GĐ12 — Testing: chiến lược & tự động hoá
GĐ12 — Testing: chiến lược & tự động hoá
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:
/ 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 machineQuy 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 | Chạy được, cần chút cấu hình | Mặc định của nest new |
| 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 | Jest | Là mặc định của nest new; toàn bộ tài liệu, @nestjs/testing, và ví dụ cộng đồng đều theo Jest |
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. Lợi ích tốc độ của Vitest không bù nổi chi phí khi bạn là người duy nhất trong hệ sinh thái Nest tự cấu hình runner, và mọi lỗi bạn gặp không tra được ở đâu.
Trong lộ trình này: DA1/DA2 (Express) dùng Vitest, DA3/DA4 (NestJS) dùng Jest. Có cả hai trong lịch sử git là điểm cộng — bạn dùng được cả hai và biết vì sao mỗi cái ở đúng chỗ của nó.
Khi Jest chậm (thường xảy ra khi test suite Nest 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 đủ.
// 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:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node', // KHÔNG phải jsdom — backend không có DOM
globals: true,
setupFiles: ['./test/setup.ts'],
// Test chạm DB không được chạy song song trên cùng một database.
// Hoặc dùng pool này, hoặc cấp mỗi worker một schema riêng (mục 5).
poolOptions: { threads: { singleThread: true } },
testTimeout: 15_000, // container DB khởi động chậm ở lần đầu
coverage: { provider: 'v8', reporter: ['text', 'lcov'] },
},
});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:
// 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' };
}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:
// ✅ Fake: hành xử như thật, test đọc dễ, không ràng buộc cài đặt
class 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.
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:
// test/setup.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { execSync } from 'node:child_process';
let container: StartedPostgreSqlContainer;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine').start();
process.env.DATABASE_URL = container.getConnectionUri();
// 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ệ.
execSync('npx prisma migrate deploy', { env: process.env, stdio: 'inherit' });
}, 120_000);
afterAll(async () => { await container.stop(); });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):
afterEach(async () => {
// 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):
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à thêm chốt an toàn trong setup.ts:
if (!/localhost|127\.0\.0\.1|testcontainers/.test(process.env.DATABASE_URL!)) {
throw new Error('Test đang trỏ vào DB không phải local — DỪNG.');
}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ụ:
// test/factories.ts
let 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 }) { /* ... */ },
};// Đọ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):
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
{ route: 'DELETE /documents/:id', role: 'MEMBER', expect: 403 }, // doc của người khác
];
it.each(cases)('$route với role=$role → $expect', async ({ route, role, expect: code }) => {
const [method, path] = route.split(/\s+/);
const req = request(app.getHttpServer())[method.toLowerCase()](resolve(path));
if (role) req.set('Authorization', `Bearer ${await tokenFor(role)}`);
await req.expect(code);
});
});// 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.
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):
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 ngay
afterEach(() => 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ụ:
// ❌ Không test được "token hết hạn" mà không chờ 30 phút thật
function isExpired(t: Token) { return t.expiresAt < new Date(); }
// ✅ Fake timers
import { vi } from 'vitest';
it('token hết hạn sau 30 phút', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const t = await svc.createResetToken(user.id);
vi.advanceTimersByTime(31 * 60 * 1000);
await expect(svc.consumeResetToken(t)).rejects.toThrow(TokenExpiredError);
vi.useRealTimers();
});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:
// 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Đ13).
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:
// ✅ 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 test
new Worker('documents', job => handleProcessDocument(job.data, deps));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.
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ụ:
// load/api.js
import 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.
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.
// vitest coverage với ngưỡng riêng cho vùng nhạy cảm
{
"coverage": {
"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â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ự:
// ❌ 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 servicePitfall. 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:
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
env: { TZ: UTC }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version-file: '.nvmrc', cache: 'npm' }
- run: npm ci # ci, KHÔNG phải install (GĐ1 mục 8)
# 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: npx prisma migrate deploy --schema=./prisma/schema.prisma
env: { DATABASE_URL: 'postgresql://...' }
- run: npm run test -- --coverage
# Testcontainers cần Docker — có sẵn trên ubuntu-latestCơ 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Đ6/GĐ8):
- Cài Vitest (hoặc dùng Jest nếu là Nest) + Testcontainers. Có chốt an toàn chặn
DATABASE_URLkhông phải local. - Viết
test/factories.tschouser,tenant,document. - 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 đỏ. - Integration test chứng minh tenant isolation: xoá
where: { tenantId }khỏi một service → test phải đỏ. - Integration test chứng minh transaction rollback khi bước giữa lỗi.
- Test idempotency của endpoint có idempotency key: gọi 2 lần → 1 bản ghi.
- MSW mock LLM/Stripe với
onUnhandledRequest: 'error'; có test cho JSON hỏng, 429 + retry, timeout. - Test worker BullMQ ở tầng handler thuần (không dựng Redis).
- Fake timers cho token hết hạn.
- 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.
- CI GitHub Actions đủ 5 bước; bật branch protection.
- Bật coverage với ngưỡng riêng cho module thanh toán.
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.
- [ ] Phân biệt stub / fake / spy / mock; nói được vì sao ưu tiên fake hơn mock.
- [ ] Dựng được Testcontainers chạy migration thật; có chốt chặn DB không phải local.
- [ ] Chọn và giải thích được chiến lược cô lập (truncate / rollback / schema-per-worker).
- [ ] Có factory; test đọc ra ngay "điều gì đang được kiểm".
- [ ] Có ma trận authorization và đã chứng minh nó bắt được lỗi bằng cách gỡ guard.
- [ ] Có test chứng minh tenant isolation và transaction rollback.
- [ ] Mock external service ở tầng HTTP, có
onUnhandledRequest: 'error', và có test cho đường lỗi (429/timeout/JSON hỏng). - [ ] Không có
sleepcố định trong test; dùng fake timers hoặc polling có timeout. - [ ] Test worker ở tầng handler thuần; có test idempotency.
- [ ] Chạy được k6 và đọc được p95/p99; hiểu vì sao không nhìn trung bình.
- [ ]
TZ=UTCcho test và CI; giải thích được vì sao. - [ ] 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.
- [ ] CI chạy lint → typecheck → test theo thứ tự chi phí; branch protection bật.
- [ ] Không dùng
retryđể che flaky; biết 6 nguyên nhân flaky và cách trị từng cá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. - 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.
- 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.
- Test cho code LLM (GĐ19–9): 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Đ19 mục 11.
