Danh sách bài viết

Bài 31: Pattern — Test Data Factory Fixture

Database seeder (bài 30) insert thẳng vào DB. Test Data Factory làm việc trước đó: generate data object hợp lệ — caller quyết định dùng object đó để gọi API, insert DB, hay fill form. Tách bạch hai trách nhiệm này giúp factory tái dùng ở nhiều layer test. Bài này cover cú pháp factory function với @faker-js/faker, cách wrap vào fixture, deterministic seed để bug reproducible, combine factory với seeder, multi-locale, limitation và pitfall.

28/05/2026
15 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 sự khác nhau giữa Test Data Factory và Database Seeder về trách nhiệm.
  • Viết được factory function buildUser, buildOrder với @faker-js/faker, support Partial<T> override.
  • Wrap factory vào Playwright fixture để test có thể destructure trực tiếp từ tham số.
  • Dùng faker.seed() để tạo deterministic data — cùng seed → cùng output → bug reproducible.
  • Combine factory với seedUser fixture (bài 30) trong cùng một test.
  • Dùng fakerVI cho locale Việt Nam.
  • Nhận biết 4 pitfall điển hình khi dùng faker trong test.
2

Factory vs Seeder — Ranh Giới Trách Nhiệm

Cả hai pattern đều phục vụ mục tiêu "chuẩn bị data cho test" — nhưng ở hai tầng khác nhau:

Tiêu chí Test Data Factory Database Seeder (bài 30)
Output Plain object (chưa lưu) Record đã tồn tại trong DB
Phụ thuộc DB Không Có — cần connection, schema
Caller làm gì tiếp Tự quyết: fill form, gọi API, insert DB Data đã ready, dùng ngay
Dùng ở đâu UI test, API test, unit test Chủ yếu E2E test cần pre-existing data
Cleanup Không cần (object GC'd sau test) Phải DELETE record sau test

Một test có thể dùng cả hai: factory build object → seeder insert vào DB. Pattern này được cover ở mục 9.

Test Data Factory không thay thế seeder — chúng bổ sung nhau. Factory trả lời "data trông như thế nào", seeder trả lời "data đã sẵn trong DB".

3

Cú Pháp Factory Cơ Bản

Cài package:

npm i -D @faker-js/faker

File factories/user.ts:

import { faker } from '@faker-js/faker';

export type User = {
  email: string;
  name: string;
  password: string;
  age: number;
};

export function buildUser(overrides: Partial<User> = {}): User {
  return {
    email: faker.internet.email(),
    name: faker.person.fullName(),
    password: faker.internet.password({ length: 12 }),
    age: faker.number.int({ min: 18, max: 80 }),
    ...overrides,
  };
}

Một vài điểm cần chú ý:

  • Default value dùng faker — mỗi lần gọi buildUser() trả về object khác nhau.
  • Tham số overrides: Partial<User> — caller chỉ cần truyền field muốn pin, còn lại faker tự generate.
  • Spread ...overrides ở cuối — override luôn thắng default.
  • Export cả User type và buildUser function — test file import type để viết assertion.

File factories/order.ts tương tự:

import { faker } from '@faker-js/faker';

export type Order = {
  id: string;
  userId: string;
  amount: number;
  status: 'pending' | 'paid' | 'cancelled';
};

export function buildOrder(overrides: Partial<Order> = {}): Order {
  return {
    id: faker.string.uuid(),
    userId: faker.string.uuid(), // caller override nếu cần link với user thật
    amount: faker.number.float({ min: 10, max: 5000, fractionDigits: 2 }),
    status: faker.helpers.arrayElement(['pending', 'paid', 'cancelled']),
    ...overrides,
  };
}

Convention: 1 file factory per domain, đặt trong thư mục factories/. Tránh factory file chứa mọi thứ — khó tìm, khó maintain khi schema thay đổi.

4

Wrap Factory Vào Fixture

Factory function tự nó không cần fixture — có thể import trực tiếp trong test. Tuy nhiên, wrap vào fixture mang lại hai lợi ích:

  1. Consistent interface: test destructure { buildUser, buildOrder } cùng chỗ với page, seedUser — không import từ nhiều nơi.
  2. Dependency injection cho seed: fixture có thể access testInfo để set deterministic seed (xem mục 8).

File fixtures/factory.ts:

import { test as base } from '@playwright/test';
import { buildUser } from '../factories/user';
import { buildOrder } from '../factories/order';

export const test = base.extend<{
  buildUser: typeof buildUser;
  buildOrder: typeof buildOrder;
}>({
  buildUser: async ({}, use) => {
    await use(buildUser);
  },
  buildOrder: async ({}, use) => {
    await use(buildOrder);
  },
});

Vì factory không tạo resource nào cần cleanup, fixture chỉ có await use(...) — không cần logic sau đó. Đây là điểm khác biệt lớn so với seedUser phải track ID và DELETE.

Nếu project đã có fixture file chứa seedUser, extend thêm vào cùng file thay vì tạo file riêng:

// fixtures/index.ts — merge factory + db fixtures
import { test as dbTest } from './db';
import { buildUser } from '../factories/user';
import { buildOrder } from '../factories/order';

export const test = dbTest.extend<{
  buildUser: typeof buildUser;
  buildOrder: typeof buildOrder;
}>({
  buildUser: async ({}, use) => {
    await use(buildUser);
  },
  buildOrder: async ({}, use) => {
    await use(buildOrder);
  },
});

export { expect } from '@playwright/test';

Test file chỉ cần import từ 1 chỗ: import { test, expect } from '../fixtures';

5

Sử Dụng Trong Test

Import test từ fixture file và destructure buildUser cùng page:

// tests/signup.spec.ts
import { test, expect } from '../fixtures';

test('signup với random user', async ({ page, buildUser }) => {
  const user = buildUser();

  await page.goto('/signup');
  await page.getByLabel('Email').fill(user.email);
  await page.getByLabel('Name').fill(user.name);
  await page.getByLabel('Password').fill(user.password);
  await page.getByRole('button', { name: 'Đăng ký' }).click();

  await expect(page.getByText(`Xin chào, ${user.name}`)).toBeVisible();
});

test('signup với email cố định (kiểm tra duplicate email)', async ({ page, buildUser }) => {
  // Override chỉ email — các field khác vẫn random
  const user = buildUser({ email: '[email protected]' });

  await page.goto('/signup');
  await page.getByLabel('Email').fill(user.email);
  await page.getByLabel('Name').fill(user.name);
  await page.getByLabel('Password').fill(user.password);
  await page.getByRole('button', { name: 'Đăng ký' }).click();

  // Test lần 2 với cùng email sẽ thấy lỗi duplicate
  await page.goto('/signup');
  await page.getByLabel('Email').fill(user.email);
  await page.getByLabel('Name').fill(buildUser().name); // name khác
  await page.getByLabel('Password').fill(buildUser().password);
  await page.getByRole('button', { name: 'Đăng ký' }).click();

  await expect(page.getByText('Email đã tồn tại')).toBeVisible();
});

Test 1 dùng data hoàn toàn random — không conflict với lần chạy trước vì email khác mỗi lần. Test 2 chủ động pin email để kiểm tra edge case duplicate.

6

Use Cases

Realistic Data — Phát Hiện Bug Ẩn

Hardcode '[email protected]' hay 'Test User' không phản ánh dữ liệu thực tế. App có thể fail âm thầm với:

  • Name chứa apostrophe: "O'Brien" — SQL injection nếu không escape đúng.
  • Email địa phương hóa: "[email protected]" — regex validation sai.
  • Name Unicode: "José García" — encoding bug trong DB hoặc email template.
  • Password với ký tự đặc biệt: "P@$$w0rd!" — form encode không đúng.

Faker generate đủ loại format — test chạy nhiều lần có cơ hội catch những bug này.

Random Unique — Tránh Unique Constraint

Với hardcode email, test thứ 2 trong cùng run (hoặc re-run) thất bại vì unique constraint. Faker tạo email khác mỗi lần — không cần cleanup email sau test.

So sánh:

// Hardcode — conflict khi chạy lần 2
const user = { email: '[email protected]', name: 'Test User' };

// Factory — unique mỗi lần, không conflict
const user = buildUser();
// email: "[email protected]" → lần sau khác hoàn toàn

Override cho Edge Case

Factory không cản trở việc test specific scenario:

// User chưa đủ tuổi
const minorUser = buildUser({ age: 16 });

// User với email domain cụ thể
const corpUser = buildUser({ email: faker.internet.email({ provider: 'company.com' }) });

// Order đã cancel
const cancelledOrder = buildOrder({ status: 'cancelled', amount: 0 });
7

Related Entities — Build Nhiều Object Liên Kết

Khi test cần user + order liên kết với nhau, fixture có thể expose builder kết hợp:

// fixtures/factory.ts — thêm buildOrderWithUser
import { buildUser, User } from '../factories/user';
import { buildOrder, Order } from '../factories/order';

type OrderWithUser = { user: User; order: Order };

export const test = base.extend<{
  buildUser: typeof buildUser;
  buildOrder: typeof buildOrder;
  buildOrderWithUser: (overrides?: Partial<Order>) => OrderWithUser;
}>({
  buildUser: async ({}, use) => {
    await use(buildUser);
  },
  buildOrder: async ({}, use) => {
    await use(buildOrder);
  },
  buildOrderWithUser: async ({}, use) => {
    const builder = (overrides: Partial<Order> = {}): OrderWithUser => {
      const user = buildUser();
      const order = buildOrder({ userId: user.email, ...overrides }); // link bằng email nếu ID chưa có
      return { user, order };
    };
    await use(builder);
  },
});

Dùng trong test:

test('order detail hiển thị thông tin user', async ({ page, buildOrderWithUser }) => {
  const { user, order } = buildOrderWithUser({ status: 'paid' });

  // Giả sử app nhận data qua query param hoặc đã seed trước
  await page.goto(`/orders/${order.id}?mock=true`);
  await expect(page.getByText(user.name)).toBeVisible();
  await expect(page.getByText(order.amount.toString())).toBeVisible();
});

Pattern này đặc biệt hữu ích khi test dùng mock server hoặc API intercept (MSW) — factory generate object, test setup mock response với object đó, Playwright hit mock thay vì server thật.

8

Deterministic Seed — Reproducible Bug

Random data có vấn đề: test fail trên CI nhưng không reproduce được trên local vì data khác. Faker hỗ trợ seed để fix điều này — cùng seed number → cùng sequence random.

Gọi faker.seed() trong fixture setup, trước khi use():

buildUser: async ({}, use, testInfo) => {
  // seed dựa vào workerIndex + parallelIndex — unique per test, deterministic per run
  faker.seed(testInfo.workerIndex * 1000 + testInfo.parallelIndex);
  await use(buildUser);
},

Với cách này:

  • Cùng test, cùng worker config → cùng seed → cùng data → bug reproducible.
  • Hai test khác nhau trong cùng run nhận seed khác → không dùng chung sequence random.

Nếu muốn reproduce cụ thể một lần fail, log seed ra:

buildUser: async ({}, use, testInfo) => {
  const seed = testInfo.workerIndex * 1000 + testInfo.parallelIndex;
  console.log(`[factory] faker seed: ${seed} (test: ${testInfo.title})`);
  faker.seed(seed);
  await use(buildUser);
},

Sau đó hardcode seed đó khi debug: faker.seed(2003) — lấy lại đúng data đã gây fail.

Lưu ý: faker.seed() set seed global cho faker instance. Nếu nhiều test chạy đồng thời trong cùng process và dùng chung faker instance, seed của test này ảnh hưởng test kia. Giải pháp: dùng new Faker({ locale: ... }) tạo instance riêng per test thay vì dùng global faker.

9

Combine Factory Với Seeder (Bài 30)

Workflow thường gặp nhất: factory build object → seeder insert vào DB. Test nhận user đã có trong DB với data realistic.

// test dùng cả buildUser (factory) và seedUser (seeder)
test('user có thể đăng nhập', async ({ page, buildUser, seedUser }) => {
  // 1. Factory generate data object
  const userData = buildUser();

  // 2. Seeder insert vào DB, trả về record đã có ID
  const user = await seedUser({
    email: userData.email,
    name: userData.name,
    // password thường hash trước khi insert — tùy schema
  });

  // 3. Test dùng data đã trong DB
  await page.goto('/login');
  await page.getByLabel('Email').fill(user.email);
  await page.getByLabel('Password').fill(userData.password); // raw password để fill form
  await page.getByRole('button', { name: 'Đăng nhập' }).click();

  await expect(page.getByText(`Xin chào, ${user.name}`)).toBeVisible();
});

Tại sao tách ra thay vì cho seeder tự generate data? Vì có test chỉ cần object (fill form, mock API) mà không cần DB. Nếu seeder tự dùng faker, test không có cách lấy lại data đó để assertion.

Pattern tách factory và seeder cũng cho phép test unit của factory function không cần DB connection — gọi buildUser() và assert shape của object.

10

Multi-Locale

@faker-js/faker hỗ trợ nhiều locale. Import locale-specific faker thay vì global faker:

import { fakerVI } from '@faker-js/faker'; // Tiếng Việt
import { fakerJA } from '@faker-js/faker'; // Tiếng Nhật
import { fakerDE } from '@faker-js/faker'; // Tiếng Đức

// fakerVI generate tên tiếng Việt
const viUser = {
  name: fakerVI.person.fullName(), // "Nguyễn Văn An", "Trần Thị Bình", ...
  address: fakerVI.location.streetAddress(), // địa chỉ format Việt Nam
};

// fakerJA generate tên Kanji
const jaUser = {
  name: fakerJA.person.fullName(), // "田中 太郎", "鈴木 花子", ...
};

Dùng multi-locale khi app có tính năng i18n cần test, hoặc khi test form validation với input không phải ASCII. Ví dụ: kiểm tra app có hiển thị đúng tên "Nguyễn Thị Ánh Nguyệt" không bị mất dấu.

Tạo factory locale-aware:

import { fakerVI, fakerEN } from '@faker-js/faker';

type Locale = 'vi' | 'en';

export function buildUser(overrides: Partial<User> = {}, locale: Locale = 'en'): User {
  const f = locale === 'vi' ? fakerVI : fakerEN;
  return {
    email: f.internet.email(),
    name: f.person.fullName(),
    password: fakerEN.internet.password({ length: 12 }), // password không cần locale
    age: f.number.int({ min: 18, max: 80 }),
    ...overrides,
  };
}
11

Thư Viện Thay Thế

@faker-js/faker là lựa chọn phổ biến nhất (fork từ faker.js gốc sau khi tác giả gốc xóa repo năm 2022). Một số alternative:

Thư viện Điểm mạnh Phù hợp khi
@faker-js/faker Rất nhiều locale, API đa dạng (internet, person, company, finance...) Đa số dự án
chance.js API đơn giản, nhỏ hơn, có seed support Project không cần multi-locale
casual.js Dễ dùng, lazy evaluation Script seed đơn giản
fishery Factory-bot inspired, trait support, sequence auto-increment Team từ Rails/Ruby muốn pattern quen thuộc

fishery đáng nhắc riêng vì support trait (factory variant) và transient params — pattern khác với faker-centric approach:

import { Factory } from 'fishery';
import { faker } from '@faker-js/faker';

const userFactory = Factory.define<User>(() => ({
  email: faker.internet.email(),
  name: faker.person.fullName(),
  password: faker.internet.password({ length: 12 }),
  age: faker.number.int({ min: 18, max: 80 }),
}));

// Dùng
const user = userFactory.build();
const adminUser = userFactory.build({ role: 'admin' });
const multipleUsers = userFactory.buildList(3);
12

Limitation

Assertion Specific Value Khó Hơn

Khi data random, không thể viết:

await expect(page.getByText('John Doe')).toBeVisible(); // sai — name thay đổi mỗi lần

Phải dùng biến:

const user = buildUser();
await expect(page.getByText(user.name)).toBeVisible(); // đúng

Điều này thực ra tốt hơn — test không phụ thuộc vào hardcode string, dễ refactor hơn.

Seed Cần Quản Lý Cẩn Thận

Nếu dùng seed deterministic, mọi test trong cùng worker dùng chung faker instance sẽ nhận cùng sequence. Test A gọi buildUser() trước, test B gọi sau — cùng seed nhưng data khác nhau vì sequence tiếp tục. Không phải bug, nhưng cần hiểu để tránh nhầm khi debug.

Schema Drift

Factory định nghĩa thủ công type User. Khi DB schema thêm field bắt buộc, factory không tự biết — TypeScript chỉ báo nếu field đó có trong type. Phải cập nhật factory type và buildUser function đồng thời với migration.

Với Prisma: có thể import type trực tiếp từ Prisma client thay vì tự định nghĩa — đảm bảo factory luôn đồng bộ schema:

import { Prisma } from '@prisma/client';
type UserInput = Prisma.UserCreateInput;

export function buildUser(overrides: Partial<UserInput> = {}): UserInput { ... }
13

Common Pitfalls

Pitfall 1 — Random Data Conflict Unique Constraint

Faker generate email random nhưng không guaranteed unique — xác suất trùng thấp nhưng không bằng 0, nhất là khi chạy nhiều nghìn test.

// email faker có thể trùng nếu chạy đủ nhiều lần
const user1 = buildUser(); // "[email protected]"
const user2 = buildUser(); // "[email protected]" (xác suất nhỏ nhưng có)

Fix: append timestamp hoặc uuid vào email thay vì dùng thuần faker:

email: `${faker.internet.username()}-${Date.now()}@test.example`,

Pitfall 2 — Hardcode Vài Field, Quên Random Phần Còn Lại

Developer override nhiều field thủ công, giảm coverage của random:

// Không cần thiết — buildUser() đã handle tất cả
const user = buildUser({
  email: '[email protected]', // hardcode
  name: 'Test User',          // hardcode
  password: 'password123',    // hardcode
  age: 25,                    // hardcode
});

Chỉ override field cần thiết cho scenario cụ thể. Nếu test không quan tâm đến name, để faker generate — tăng coverage, phát hiện bug không ngờ tới.

Pitfall 3 — Quên Locale Khi Test Dữ Liệu Việt Nam

Dùng global faker (locale en) generate địa chỉ Việt Nam:

faker.location.city(); // "Detroit", "Phoenix" — không phải "Hà Nội", "TP. HCM"

Khi test form có field địa chỉ Việt Nam, dùng fakerVI. Khi test validation chấp nhận Unicode, dùng locale phù hợp thay vì chỉ ASCII.

Pitfall 4 — Dùng faker.seed() Global Trong Parallel Tests

Gọi faker.seed(n) trong một test ảnh hưởng tất cả test chạy cùng process, cùng faker instance:

// Trong test A — không nhận ra mình đang reset seed cho cả process
faker.seed(42);
const user = buildUser(); // deterministic

// Test B chạy sau, trên cùng worker — seed đã bị A đặt lại
const user2 = buildUser(); // không còn random như kỳ vọng

Fix: set seed trong fixture (không phải trong test body), hoặc dùng new Faker({ locale: [en] }) tạo instance riêng per test.

14

Tổng Kết

  • Factory generate plain object — caller quyết định dùng để fill form, gọi API, hay insert DB. Seeder insert thẳng vào DB.
  • Convention: 1 file factory per domain (factories/user.ts, factories/order.ts), export cả type và builder function.
  • Fixture wrap factory để test destructure cùng chỗ với page, seedUser.
  • Dùng faker.seed(workerIndex * 1000 + parallelIndex) cho deterministic data — reproducible khi debug CI fail.
  • Combine factory + seeder: factory build object → seeder insert DB → test có data realistic đã sẵn.
  • Multi-locale: import fakerVI, fakerJA thay vì global faker khi test i18n.
  • Tránh: seed global sau khi test bắt đầu, hardcode toàn bộ field, quên locale cho data địa phương.
15

Bài Tập Củng Cố

Câu 1

Test Data Factory khác Database Seeder ở điểm gì? Một test cần cả hai không — và nếu có, luồng xử lý là gì?

Đáp án

Factory generate plain object (chưa persist). Seeder insert vào DB. Factory không phụ thuộc DB — có thể dùng trong unit test, API test, UI test.

Một test có thể cần cả hai: factory build object có data realistic, seeder nhận object đó và insert vào DB. Test sau đó login/navigate và thấy data đó trên UI.

Câu 2

Đoạn code này có vấn đề gì?

test('kiểm tra profile', async ({ page }) => {
  faker.seed(99);
  const user = buildUser();
  // ...
});
Đáp án

Gọi faker.seed(99) trực tiếp trong test body sẽ reset seed global của faker instance. Nếu Playwright chạy nhiều test trên cùng worker process, test này làm ảnh hưởng sequence random của tất cả test chạy sau trong cùng process.

Fix: set seed trong fixture setup (buildUser: async ({}, use, testInfo) => { faker.seed(...); await use(buildUser); }), không phải trong test body.

Câu 3

Tại sao assertion expect(page.getByText('Jane Smith')).toBeVisible() sai khi dùng factory? Sửa lại như thế nào?

Đáp án

Faker generate name khác nhau mỗi lần — 'Jane Smith' chỉ đúng trong một lần chạy cụ thể. Lần sau faker trả về name khác, test fail.

Sửa: lưu kết quả factory vào biến và dùng biến đó trong assertion:

const user = buildUser();
// ...fill form với user.name...
await expect(page.getByText(user.name)).toBeVisible();

Câu 4

Project test app có form địa chỉ cho khách hàng Việt Nam. faker.location.city() trả về "Houston", "Denver". Sửa factory thế nào?

Đáp án

Import fakerVI thay vì global faker:

import { fakerVI } from '@faker-js/faker';

export function buildAddress() {
  return {
    city: fakerVI.location.city(),         // "Hà Nội", "TP. Hồ Chí Minh", ...
    street: fakerVI.location.streetAddress(),
    name: fakerVI.person.fullName(),       // "Nguyễn Văn An", ...
  };
}

Câu 5

Viết fixture buildProduct cho type Product { name: string; price: number; sku: string; inStock: boolean }. Faker API nào phù hợp cho từng field?

Đáp án
import { faker } from '@faker-js/faker';

export type Product = {
  name: string;
  price: number;
  sku: string;
  inStock: boolean;
};

export function buildProduct(overrides: Partial<Product> = {}): Product {
  return {
    name: faker.commerce.productName(),          // "Ergonomic Wooden Chair"
    price: faker.number.float({ min: 1, max: 10000, fractionDigits: 2 }),
    sku: faker.string.alphanumeric({ length: 8, casing: 'upper' }), // "AB3X9KL2"
    inStock: faker.datatype.boolean(),
    ...overrides,
  };
}
16

Bài Tiếp Theo

Bài 32 chuyển sang chủ đề mới: merge tests — cách tổ chức và gộp test file trong Playwright.

Bài 32: Merge Tests