Danh sách bài viết

Bài 30: Pattern — Database Seeder Fixture

Thay vì gọi API để tạo test data (vài trăm ms đến vài giây mỗi record), database seeder fixture ghi thẳng vào DB — bỏ qua HTTP overhead, validation layer, middleware. Bài này cover hai approach (direct SQL và ORM Prisma), cơ chế worker isolation để tránh conflict khi chạy song song, transaction rollback pattern để cleanup tự động, và các pitfall hay gặp khi dùng seeder trong CI.

27/05/2026
16 phút đọc
0 lượt xem
1

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 seedUser dùng pg (direct SQL) với cleanup tự động.
  • Xây dựng được fixture seedUser dùng Prisma ORM.
  • Áp dụng workerIndex prefix để 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.

2

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:

  1. Gọi API: POST /users, POST /orders, v.v. — đi qua HTTP stack đầy đủ.
  2. 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.

3

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.

4

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:

  • dbPool khai báo scope worker — 1 Pool object tồn tại suốt vòng đời worker. Không tạo connection mới cho từng test.
  • seedUser cũng scope worker để dùng chung dbPool. Worker scope fixture không thể depend test scope fixture.
  • createdIds là mảng nội bộ — track tất cả ID đã insert để cleanup sau use(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).
5

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
});

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.

6

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 đó.

7

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.

8

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.

9

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.

10

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

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 max trong Pool config.
  • Dùng PgBouncer hoặc connection pooler ở tầng DB.
  • Tăng max_connections trong 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.

12

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.

13

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 workerIndex prefix 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.
14

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'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ý:

  1. Flush Redis cache sau khi seed (dùng redis.flushdb() hoặc redis.del('users:list')).
  2. Disable cache trong test environment bằng env var.
  3. Dùng short TTL trong test config để cache expire nhanh.
15

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.

Bài 31: Pattern — Test Data Factory Fixture