Mục lục
- Mục Tiêu Bài Học
- Tại Sao Bypass API Khi Seed
- So Sánh Performance
- Approach 1 — Direct SQL Query (pg)
- Sử Dụng seedUser Trong Test
- Approach 2 — ORM Prisma
- Worker Isolation — Tránh Conflict Song Song
- Transaction Rollback Pattern
- Use Cases Điển Hình
- Real DB vs In-Memory DB
- Limitation Cần Biết
- Common Pitfalls
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau khi hoàn thành bài này, bạn sẽ:
- Giải thích được tại sao seed trực tiếp vào DB nhanh hơn gọi API để tạo test data.
- Xây dựng được fixture
seedUserdùngpg(direct SQL) với cleanup tự động. - Xây dựng được fixture
seedUserdùng Prisma ORM. - Áp dụng
workerIndexprefix để tránh unique constraint conflict khi chạy song song. - Hiểu transaction rollback pattern như một cơ chế cleanup không cần DELETE tường minh.
- Biết khi nào dùng real DB, khi nào dùng in-memory DB cho test.
- Tránh 4 pitfall điển hình: quên cleanup, hardcode ID, schema drift, không dùng transaction.
Bài này không lặp lại pattern API client fixture (bài 29). Focus vào seeding trực tiếp qua DB layer.
Tại Sao Bypass API Khi Seed
Khi test cần dữ liệu sẵn có trước khi chạy (pre-condition), có hai cách tạo data:
- Gọi API:
POST /users,POST /orders, v.v. — đi qua HTTP stack đầy đủ. - Seed thẳng vào DB:
INSERT INTO users ...— bỏ qua HTTP, authentication, validation middleware.
Với UI test (end-to-end), mục tiêu là kiểm tra hành vi UI, không phải kiểm tra API tạo dữ liệu. Dữ liệu setup là phương tiện, không phải mục tiêu test. Vì vậy seed qua DB là hợp lý khi:
- Test cần nhiều record (hàng chục đến hàng trăm) — vd dashboard, pagination.
- Cần setup state phức tạp mà API không expose — vd order có
status: 'shipped'nhưng API chỉ cho tạo order ởstatus: 'pending'. - Cần seed quan hệ nhiều bảng trong 1 transaction — vd user + order + payment cùng lúc.
- Test suite chạy trên CI với hàng trăm test case song song — mỗi ms tiết kiệm được đều tích lũy.
Nhược điểm cần nhận thức: seeder phụ thuộc vào schema DB. Khi schema thay đổi, seeder có thể fail trước khi migration chạy. Bài Limitation đề cập cụ thể hơn.
So Sánh Performance
Số liệu ước tính trong điều kiện typical (local Postgres, API chạy cùng máy):
| Phương pháp | Thời gian / record | 100 records |
|---|---|---|
API call (POST /resource) |
~500ms – 2s | ~50s – 3 phút |
Direct DB INSERT |
~10 – 50ms | ~1 – 5 giây |
Bulk INSERT (1 query) |
~5 – 15ms tổng | <1 giây |
Khoảng cách lớn nhất đến từ:
- HTTP round-trip overhead: TCP handshake, TLS (nếu có), parse request, serialize response.
- Middleware stack: auth middleware, rate limiter, validation schema (zod/joi), business logic.
- ORM query layer: API thường gọi DB qua ORM với nhiều join/include hơn mức cần thiết.
Direct DB INSERT chỉ có 1 network hop (app process → DB process), không qua HTTP.
Approach 1 — Direct SQL Query (pg)
Approach này dùng pg (node-postgres) để tạo connection pool và gửi raw SQL. Phù hợp khi project không dùng ORM, hoặc khi cần control chính xác câu query.
Cài package:
npm i -D pg @types/pg
File fixtures/db.ts:
import { Pool } from 'pg';
import { test as base } from '@playwright/test';
type User = {
id: string;
email: string;
name: string;
};
// Tách TestFixtures (scope: test) và WorkerFixtures (scope: worker)
type WorkerDbFixtures = {
dbPool: Pool;
seedUser: (overrides?: Partial<User>) => Promise<User>;
};
export const test = base.extend<{}, WorkerDbFixtures>({
// dbPool: worker scope — 1 pool per worker, tái dùng qua mọi test
dbPool: [
async ({}, use) => {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5, // tối đa 5 connection per pool (per worker)
});
await use(pool);
await pool.end(); // đóng pool sau khi worker xong tất cả test
},
{ scope: 'worker' },
],
// seedUser: worker scope — cùng worker dùng chung hàng seed, cleanup cuối worker
seedUser: [
async ({ dbPool }, use, workerInfo) => {
const createdIds: string[] = [];
const seed = async (overrides: Partial<User> = {}): Promise<User> => {
// workerIndex prefix đảm bảo unique key khi nhiều worker chạy song song
const id = `user-w${workerInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const user: User = {
id,
email: `${id}@test.example`,
name: 'Test User',
...overrides,
};
await dbPool.query(
'INSERT INTO users (id, email, name) VALUES ($1, $2, $3)',
[user.id, user.email, user.name]
);
createdIds.push(user.id);
return user;
};
await use(seed);
// Cleanup: chạy dù test pass hay fail
if (createdIds.length > 0) {
await dbPool.query(
'DELETE FROM users WHERE id = ANY($1::text[])',
[createdIds]
);
}
},
{ scope: 'worker' },
],
});
Một vài điểm đáng chú ý trong code trên:
dbPoolkhai báo scopeworker— 1 Pool object tồn tại suốt vòng đời worker. Không tạo connection mới cho từng test.seedUsercũng scopeworkerđể dùng chungdbPool. Worker scope fixture không thể depend test scope fixture.createdIdslà mảng nội bộ — track tất cả ID đã insert để cleanup sauuse(seed).- Cleanup dùng
ANY($1::text[])— 1 DELETE cho tất cả ID, không loop từng cái. Math.random()trong ID tránh collision khi cùng worker seed nhiều record trong 1ms (timestamp trùng).
Sử Dụng seedUser Trong Test
Import test từ file fixture thay vì từ @playwright/test:
// tests/login.spec.ts
import { test } from '../fixtures/db';
import { expect } from '@playwright/test';
test('user nhìn thấy tên mình sau khi đăng nhập', async ({ page, seedUser }) => {
// seed user với email cụ thể để điều khiển test
const user = await seedUser({ email: '[email protected]', name: 'Alice' });
await page.goto('/login');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Mật khẩu').fill('test-password-123');
await page.getByRole('button', { name: 'Đăng nhập' }).click();
await expect(page.getByText(`Xin chào, ${user.name}`)).toBeVisible();
});
test('admin thấy danh sách users', async ({ page, seedUser }) => {
// Seed nhiều user cùng lúc — mỗi lần gọi seed() là 1 INSERT
await seedUser({ name: 'User A' });
await seedUser({ name: 'User B' });
await seedUser({ name: 'User C' });
await page.goto('/admin/users');
await expect(page.getByRole('row')).toHaveCount(4); // 3 seeded + 1 header row
});
Vì seedUser là worker scope, cleanup chỉ chạy sau khi worker hoàn thành tất cả test, không phải sau mỗi test. Nếu cần cleanup sau từng test, đổi scope thành test — nhưng sẽ tạo DB connection mới mỗi test (tốn hơn).
Khi seed nhiều test cùng worker dùng cùng email, dễ bị unique constraint. Nên để email tự generate (ID-based) và chỉ override khi thực sự cần email cố định.
Approach 2 — ORM Prisma
Nếu project dùng Prisma, tận dụng Prisma client để seed — type-safe hơn raw SQL, ít nguy cơ typo tên cột.
File fixtures/prisma-db.ts:
import { PrismaClient, Prisma } from '@prisma/client';
import { test as base } from '@playwright/test';
type WorkerPrismaFixtures = {
prisma: PrismaClient;
seedUser: (overrides?: Prisma.UserCreateInput) => Promise<{ id: string; email: string; name: string | null }>;
};
export const test = base.extend<{}, WorkerPrismaFixtures>({
prisma: [
async ({}, use) => {
const client = new PrismaClient({
datasources: {
db: { url: process.env.DATABASE_URL },
},
});
await use(client);
await client.$disconnect();
},
{ scope: 'worker' },
],
seedUser: [
async ({ prisma }, use, workerInfo) => {
const createdIds: string[] = [];
const seed = async (overrides: Prisma.UserCreateInput = {}) => {
const suffix = `w${workerInfo.workerIndex}-${Date.now()}`;
const user = await prisma.user.create({
data: {
email: `test-${suffix}@test.example`,
name: 'Test User',
...overrides,
},
select: { id: true, email: true, name: true },
});
createdIds.push(user.id);
return user;
};
await use(seed);
// Prisma cleanup — deleteMany với filter
if (createdIds.length > 0) {
await prisma.user.deleteMany({
where: { id: { in: createdIds } },
});
}
},
{ scope: 'worker' },
],
});
So sánh nhanh hai approach:
| Tiêu chí | Direct SQL (pg) | Prisma ORM |
|---|---|---|
| Type-safety | Thủ công (tự định nghĩa type) | Auto-generated từ schema |
| Tốc độ | Nhanh hơn một chút (không có overhead ORM) | Tương đương với query đơn giản |
| Relation seed | Cần viết JOIN / multiple query thủ công | create({ data: { orders: { create: [...] } } }) |
| Schema drift | Không báo lỗi compile — fail lúc runtime | TypeScript compile lỗi ngay nếu field sai |
| Phụ thuộc | Chỉ cần pg |
Cần Prisma đã setup trong project |
Drizzle ORM hay TypeORM đều có approach tương tự Prisma — thay thế prisma.user.create() bằng API tương ứng của ORM đó.
Worker Isolation — Tránh Conflict Song Song
Khi Playwright chạy với workers: 4, 4 worker process chạy đồng thời, mỗi cái seed dữ liệu riêng vào cùng DB. Nếu seed dùng ID hoặc email cố định, unique constraint sẽ fail:
// SAI — dễ fail khi parallel
const user = await prisma.user.create({
data: { email: '[email protected]' }, // Worker 0 và Worker 1 cùng seed cái này
});
Giải pháp: prefix ID/email bằng workerInfo.workerIndex:
// ĐÚNG — mỗi worker có namespace riêng
const suffix = `w${workerInfo.workerIndex}-${Date.now()}`;
// Worker 0: email = [email protected]
// Worker 1: email = [email protected]
// Worker 2: email = [email protected]
Ngoài email/ID, cần cẩn thận với các trường unique khác: username, slug, phone, sku. Bất kỳ column nào có unique constraint đều cần strategy tương tự.
Với workerInfo.parallelIndex (khác workerIndex): parallelIndex là index trong lần chạy hiện tại, còn workerIndex là index lifetime của worker. Dùng workerIndex cho seeding vì nó unique hơn qua các lần retry.
Transaction Rollback Pattern
Thay vì track ID và DELETE tường minh, có một pattern khác: wrap mỗi test trong 1 transaction và ROLLBACK sau test. Mọi INSERT trong test tự động bị hoàn tác.
// fixtures/db-transaction.ts
import { Pool, PoolClient } from 'pg';
import { test as base } from '@playwright/test';
type TransactionFixtures = {
dbPool: Pool;
dbTx: PoolClient; // test-scope: mỗi test 1 transaction riêng
};
export const test = base.extend<TransactionFixtures, { _dbPool: Pool }>({
_dbPool: [
async ({}, use) => {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await use(pool);
await pool.end();
},
{ scope: 'worker' },
],
// Scope test — mỗi test mở transaction riêng, rollback sau khi test xong
dbPool: async ({}, use) => {
// expose pool trực tiếp nếu cần gọi ngoài transaction
await use(undefined as unknown as Pool); // placeholder, dùng dbTx thay thế
},
dbTx: async ({ _dbPool }, use) => {
const client = await _dbPool.connect();
await client.query('BEGIN');
await use(client); // test nhận client đã trong transaction
// Rollback dù test pass hay fail — mọi INSERT đều bị hoàn tác
await client.query('ROLLBACK');
client.release();
},
});
Cách dùng đơn giản hơn — không cần quản lý cleanup:
test('seed và verify trong transaction', async ({ dbTx, page }) => {
// INSERT này sẽ bị ROLLBACK sau test — không rác lại DB
await dbTx.query(
'INSERT INTO products (id, name, price) VALUES ($1, $2, $3)',
['prod-1', 'Test Product', 99.99]
);
await page.goto('/products');
await expect(page.getByText('Test Product')).toBeVisible();
// Sau test: ROLLBACK — products table trở về trạng thái trước
});
Ưu điểm của transaction rollback:
- Không cần track ID — không quên cleanup.
- Atomic isolation — các test không thấy data của nhau.
- Nhanh hơn DELETE vì ROLLBACK không ghi redo log.
Nhược điểm:
- App server cũng phải dùng cùng connection/transaction mới thấy data — thường không khả thi với UI test vì app server có connection riêng.
- Transaction rollback pattern phù hợp hơn với integration test thuần backend, không phải E2E UI test.
Với UI E2E test, nên dùng approach DELETE (bài 4 / bài 6) vì app server đọc từ DB qua connection riêng — app server cần commit data mới đọc được.
Use Cases Điển Hình
Heavy Seed — Hàng Trăm Record
Dashboard test cần 200 orders để kiểm tra pagination, chart aggregation, filter. Gọi API 200 lần mất 2-6 phút. Seed qua DB bulk INSERT mất dưới 1 giây:
// Seed 200 orders bằng 1 query (VALUES batch)
const values = orders.map((o, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(', ');
const params = orders.flatMap(o => [o.id, o.userId, o.amount]);
await dbPool.query(`INSERT INTO orders (id, user_id, amount) VALUES ${values}`, params);
Specific State — Trạng Thái API Không Expose
Test flow "order bị huỷ sau khi thanh toán thất bại 3 lần". API production chỉ cho tạo order mới, không cho tạo order ở trạng thái đặc biệt. Seed trực tiếp:
await dbPool.query(
`INSERT INTO orders (id, status, payment_failures) VALUES ($1, $2, $3)`,
['order-test-1', 'cancelled', 3]
);
Cross-Table Relation — Seed Trong 1 Transaction
Test cần user + order + payment cùng lúc, với foreign key constraints:
const client = await dbPool.connect();
try {
await client.query('BEGIN');
await client.query('INSERT INTO users (id, email) VALUES ($1, $2)', [userId, email]);
await client.query('INSERT INTO orders (id, user_id, status) VALUES ($1, $2, $3)', [orderId, userId, 'shipped']);
await client.query('INSERT INTO payments (id, order_id, status) VALUES ($1, $2, $3)', [payId, orderId, 'failed']);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
Bọc trong transaction đảm bảo toàn bộ set data được insert hoặc không insert gì cả — tránh partial state nếu 1 trong 3 INSERT fail.
Real DB vs In-Memory DB
Một số team dùng in-memory DB (SQLite, pg-mem) để test nhanh hơn không cần Postgres thật. Đây là trade-off cần cân nhắc:
| Tiêu chí | Real DB (Postgres) | In-memory (SQLite / pg-mem) |
|---|---|---|
| Tốc độ setup | Cần khởi động Postgres (hoặc Docker) | Khởi động trong <1s, không cần process riêng |
| Fidelity | Hành vi chính xác như production | Có thể khác: JSON operators, window functions, JSONB index |
| Isolation | Cần quản lý cleanup | Tạo DB mới mỗi test — tự isolated |
| CI cost | Cần service container (GitHub Actions: services: postgres) |
Không cần service container |
Khuyến nghị thực tế:
- Real DB cho critical E2E flow (checkout, auth, payment) — muốn test hành vi đúng với production.
- In-memory DB cho unit/integration test của service layer — ưu tiên tốc độ, không cần E2E accuracy.
- Không trộn lẫn: nếu test file dùng real DB, đừng mix in-memory DB trong cùng suite — behavior khác nhau gây flaky.
Limitation Cần Biết
Schema Drift
Seeder biết schema DB tại thời điểm viết. Khi developer thêm NOT NULL column không có default, seeder cũ sẽ fail với violates not-null constraint. Cần cập nhật seeder đồng thời với migration, không phải sau.
Dùng Prisma giúp giảm bớt vì TypeScript compile báo lỗi ngay khi field thay đổi. Raw SQL thì không.
Connection Limit
Postgres mặc định cho phép ~100 concurrent connection. Với workers: 20 và mỗi worker có pool max: 5, tổng có thể đạt 100 connection. Vượt quá sẽ nhận too many connections.
Giải pháp:
- Giảm
maxtrong Pool config. - Dùng PgBouncer hoặc connection pooler ở tầng DB.
- Tăng
max_connectionstrong Postgres config nếu test env cho phép.
State Leak Khi Cleanup Fail
Nếu worker process bị kill giữa chừng (OOM, timeout, SIGKILL), cleanup sau use(seed) không chạy. DB tích lũy garbage data theo thời gian. Cần định kỳ chạy cleanup job, hoặc dùng prefix rõ ràng (test-w%) để dễ tìm và xoá batch.
Seed Không Đồng Bộ Với App Cache
Nếu app dùng cache (Redis, in-memory) trên đỉnh DB, seed trực tiếp vào DB sẽ không invalidate cache. Test có thể thấy stale data từ cache thay vì data vừa seed. Cần flush cache sau khi seed, hoặc test với cache disabled trong test env.
Common Pitfalls
Pitfall 1 — Quên Cleanup
Seed trong beforeEach hoặc trong test body mà không có cleanup tương ứng:
// SAI — không cleanup
test.beforeEach(async () => {
await db.query('INSERT INTO users ...'); // data tích lũy sau mỗi run
});
Fix: đặt cleanup sau use() trong fixture, không seed thủ công ngoài fixture.
Pitfall 2 — Hardcode ID
// SAI — conflict ngay khi worker 0 và worker 1 cùng seed
await prisma.user.create({ data: { id: 'user-fixed-id', email: '[email protected]' } });
// ERROR: Unique constraint failed on the fields: (`id`)
Fix: luôn generate ID động với workerIndex prefix.
Pitfall 3 — Schema Drift Âm Thầm
Seed dùng raw SQL, migration thêm column is_verified BOOLEAN NOT NULL DEFAULT false. Seeder cũ vẫn chạy nhưng insert record với is_verified = false (từ DEFAULT). Test pass nhưng nếu sau này DEFAULT bị xoá, seeder fail mà không ai để ý cho đến khi CI break.
Fix: review seeder trong cùng PR với migration. Với Prisma, TypeScript compile sẽ báo ngay.
Pitfall 4 — Seed Quá Nhiều
Seed 1000 record khi test chỉ cần kiểm tra "có ít nhất 1 record". Over-seeding làm test chậm không cần thiết và query DB tốn thêm thời gian cleanup.
Fix: seed đúng số lượng test thực sự cần. Nếu test pagination cần "nhiều hơn 1 trang", seed page_size + 1 record, không seed 1000.
Tổng Kết
- Database seeder fixture seed thẳng vào DB, bỏ qua HTTP overhead — nhanh hơn API call 10-50x per record.
- Hai approach: direct SQL với
pg(linh hoạt hơn), ORM Prisma (type-safe hơn). - Luôn dùng
workerIndexprefix cho unique field để tránh constraint conflict khi chạy song song. - Track ID đã insert trong mảng nội bộ, DELETE toàn bộ sau
use()— 1 query thay vì loop. - Transaction rollback pattern phù hợp integration test backend hơn là E2E UI test.
- Real DB cho critical flow, in-memory cho unit/integration — không trộn trong cùng suite.
- Chú ý: connection limit, schema drift, cache invalidation khi dùng seeder trong môi trường phức tạp.
Bài Tập Củng Cố
Câu 1
Tại sao seed qua DB nhanh hơn gọi API? Kể tên ít nhất 3 thành phần bị bỏ qua khi bypass API.
Đáp án
Khi gọi API: TCP/HTTP overhead, authentication middleware, validation schema (zod/joi), business logic, ORM layer với join/include thừa, serialization/deserialization JSON.
Khi seed thẳng DB: chỉ có 1 hop qua socket DB — không có bất kỳ thành phần nào ở trên.
Câu 2
Fixture seedUser ở scope worker. Cleanup (DELETE) chạy vào thời điểm nào? Nếu muốn cleanup sau mỗi test thay vì sau cả worker, cần đổi gì?
Đáp án
Worker scope: cleanup chạy sau khi worker hoàn thành tất cả test được assign cho worker đó. Nếu worker chạy 10 test, cleanup chạy 1 lần sau test thứ 10.
Để cleanup sau mỗi test: đổi { scope: 'worker' } thành { scope: 'test' } (hoặc bỏ hẳn vì test là default). Lưu ý: sẽ tạo dbPool connection mới mỗi test nếu fixture không tách pool ra worker scope riêng.
Câu 3
Đoạn code sau có vấn đề gì khi chạy với workers: 4?
const seed = async () => {
await prisma.user.create({
data: { id: 'admin-user', email: '[email protected]', role: 'admin' },
});
};
Đáp án
Cả 4 worker đều cố tạo user với id: 'admin-user' và email: '[email protected]'. Worker 0 insert trước, worker 1-3 nhận lỗi Unique constraint failed. Test trong worker 1-3 sẽ fail do fixture setup error.
Fix: thêm workerIndex prefix vào ID và email.
Câu 4
Khi nào transaction rollback pattern không phù hợp với UI E2E test?
Đáp án
UI E2E test: app server có connection pool riêng đến DB. Khi test seed data trong transaction chưa COMMIT, app server không thấy data đó (read committed isolation level). Trang web hiển thị trống dù DB "có" data trong transaction đang mở.
Transaction rollback chỉ hoạt động khi test và app server dùng cùng một DB connection/transaction — thực tế trong unit/integration test backend, không phải E2E test qua browser.
Câu 5
Project dùng Redis cache trên đỉnh Postgres. Test seed user mới vào Postgres nhưng trang danh sách users vẫn hiển thị user cũ. Nguyên nhân và hướng xử lý?
Đáp án
App đọc danh sách users từ Redis cache thay vì Postgres. Seed vào Postgres không invalidate Redis — app trả về data cũ từ cache.
Hướng xử lý:
- Flush Redis cache sau khi seed (dùng
redis.flushdb()hoặcredis.del('users:list')). - Disable cache trong test environment bằng env var.
- Dùng short TTL trong test config để cache expire nhanh.
Bài Tiếp Theo
Bài 31 giới thiệu pattern tiếp theo trong nhóm Custom Fixtures: Test Data Factory — fixture tạo data object theo kiểu builder pattern, không cần hardcode từng field mỗi lần dùng trong test.
