Mục lục
- Mục Tiêu Bài Học
- Cách Fixture Nhận Dependency
- Resolve Order — Topological Sort
- Chain Dependency Pattern
- Scope Rules Khi Depend
- Shared Dependency
- Override Fixture Với Dependency
- Circular Dependency
- testInfo Và workerInfo Inject
- Pattern Fixture Composition
- Limitation
- Pitfall Thường Gặp
- Quiz
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau khi đọc xong bài này, bạn sẽ:
- Biết cú pháp khai báo dependency giữa các fixture qua destructuring tham số đầu.
- Hiểu Playwright resolve dependency order như thế nào (topological sort).
- Viết được chain dependency: option fixture → client fixture → data fixture → UI fixture.
- Biết scope rules: test fixture depend worker được, worker fixture không depend test.
- Hiểu shared dependency — nhiều fixture cùng depend một fixture, instance tạo bao nhiêu lần.
- Phân biệt cách inject
testInfo/workerInfoso với inject fixture thông thường. - Tránh được 4 pitfall phổ biến: TypeScript error, scope violation, circular dependency, over-engineering.
Cách Fixture Nhận Dependency
Fixture function nhận hai tham số: tham số đầu là object chứa tất cả fixtures có sẵn (destructure để lấy cái cần), tham số thứ hai là use.
// fixtures.ts
import { test as base, Page } from '@playwright/test';
class AdminAPI {
constructor(private request: any, private baseURL: string) {}
async getUsers() { /* ... */ }
}
export const test = base.extend<{
authedPage: Page;
adminApi: AdminAPI;
}>({
// authedPage depend: page (built-in), baseURL (option built-in)
authedPage: async ({ page, baseURL }, use) => {
await page.goto(`${baseURL}/login`);
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await use(page);
},
// adminApi depend: request (built-in), baseURL (option built-in)
adminApi: async ({ request, baseURL }, use) => {
const api = new AdminAPI(request, baseURL ?? '');
await use(api);
},
});
Cả page, request, baseURL đều là built-in fixture. Playwright inject chúng vào tham số đầu khi fixture function được gọi. Fixture authedPage nhận page và baseURL — Playwright khởi tạo chúng trước, rồi mới gọi authedPage.
Depend Custom Fixture Khác
Dependency không giới hạn ở built-in. Custom fixture cũng depend được custom fixture khác, miễn là cùng được khai báo trong cùng base.extend() hoặc trong chain extend:
export const test = base.extend<{
testUser: { email: string; id: string };
authedPage: Page;
}>({
// testUser không depend fixture nào ngoài request
testUser: async ({ request }, use) => {
const res = await request.post('/api/users', {
data: { email: '[email protected]', role: 'user' },
});
const user = await res.json();
await use(user);
// teardown: xoá user sau test
await request.delete(`/api/users/${user.id}`);
},
// authedPage depend testUser (custom) + page (built-in)
authedPage: async ({ page, testUser }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill(testUser.email);
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await use(page);
},
});
Playwright phân tích tên property trong destructuring ({ page, testUser }), từ đó biết fixture này phụ thuộc page và testUser.
Resolve Order — Topological Sort
Playwright không khởi tạo fixture theo thứ tự khai báo trong object. Thay vào đó, nó xây dựng dependency graph của tất cả fixture được dùng trong test, rồi thực hiện topological sort để tìm thứ tự hợp lệ.
Quy tắc topological sort
- Fixture không có dependency (leaf nodes) được khởi tạo trước.
- Fixture có dependency được khởi tạo sau khi tất cả dependency của nó đã sẵn sàng.
- Nếu hai fixture không phụ thuộc nhau, thứ tự tương đối giữa chúng không được đảm bảo.
Ví dụ với graph đơn giản:
page ──→ authedPage
│
testUser ───┘
Thứ tự khởi tạo: page và testUser (song song hoặc bất kỳ thứ tự) → authedPage.
Thứ tự cleanup (teardown)
Cleanup chạy ngược lại: fixture được tạo sau sẽ cleanup trước. Với ví dụ trên:
- Cleanup
authedPage(sauuse(page)) - Cleanup
testUser(xoá user) - Cleanup
page(đóng page)
Điều này đảm bảo fixture phụ thuộc luôn được teardown trước khi dependency của nó bị huỷ.
Chỉ khởi tạo fixture được dùng
Playwright chỉ khởi tạo fixture nào thực sự được dùng trong test — kể cả transitive dependency. Nếu test không destructure authedPage, toàn bộ chain không được tạo ra, kể cả testUser.
// test chỉ dùng page — authedPage và testUser không được khởi tạo
test('unauthenticated homepage', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('link', { name: 'Login' })).toBeVisible();
});
// test dùng authedPage — Playwright tạo cả testUser và page trước
test('dashboard hiển thị đúng', async ({ authedPage }) => {
await expect(authedPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
Chain Dependency Pattern
Dependency chain là pattern phổ biến nhất khi tổ chức fixture cho project lớn. Mỗi fixture chỉ biết dependency trực tiếp của nó — không cần biết fixture phía trên chain.
// fixtures.ts
import { test as base, Page } from '@playwright/test';
class APIClient {
constructor(private baseURL: string) {}
async createUser(data: object) { /* ... */ return { id: '1', email: '[email protected]' }; }
async deleteUser(id: string) { /* ... */ }
}
type Fixtures = {
apiBaseURL: string;
apiClient: APIClient;
testUser: { id: string; email: string };
authedPage: Page;
};
export const test = base.extend<Fixtures>({
// Layer 0: URL — option fixture, không depend gì
apiBaseURL: ['https://api.dev.example.com', { option: true }],
// Layer 1: Client — depend apiBaseURL
apiClient: [
async ({ apiBaseURL }, use) => {
const client = new APIClient(apiBaseURL);
await use(client);
},
{ option: true },
],
// Layer 2: Data — depend apiClient
testUser: async ({ apiClient }, use) => {
const user = await apiClient.createUser({ role: 'user' });
await use(user);
// cleanup: xoá user sau test
await apiClient.deleteUser(user.id);
},
// Layer 3: UI — depend page (built-in) + testUser
authedPage: async ({ page, testUser }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill(testUser.email);
await page.getByLabel('Password').fill('test-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await use(page);
},
});
Thứ tự khởi tạo khi test dùng authedPage:
apiBaseURL— không depend gì → khởi tạo trướcapiClient— dependapiBaseURL→ khởi tạo saupage(built-in) vàtestUser—testUserdependapiClient, song song vớipageauthedPage— dependpagevàtestUser→ khởi tạo cuối
Thứ tự teardown ngược lại: authedPage → testUser (xoá user) → apiClient → page.
Override apiBaseURL per project
Vì apiBaseURL là option fixture, config project có thể override để trỏ tới môi trường khác nhau mà không cần sửa fixture:
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'staging',
use: { apiBaseURL: 'https://api.staging.example.com' },
},
{
name: 'production-readonly',
use: { apiBaseURL: 'https://api.example.com' },
},
],
});
Scope Rules Khi Depend
Scope của fixture ảnh hưởng trực tiếp đến ai có thể depend ai:
- Test fixture có thể depend test fixture khác hoặc worker fixture.
- Worker fixture CHỈ có thể depend worker fixture khác — không được depend test fixture.
Lý do: worker fixture được khởi tạo một lần cho toàn bộ worker (nhiều test), còn test fixture khởi tạo lại cho mỗi test. Nếu worker fixture depend test fixture, không có test fixture nào tồn tại tại thời điểm worker khởi tạo.
import { test as base } from '@playwright/test';
export const test = base.extend<
{ pageWithToken: void }, // test-scope fixtures
{ dbPool: DbPool } // worker-scope fixtures
>({
// worker fixture — chỉ depend worker fixture khác (hoặc không depend gì)
dbPool: [
async ({}, use) => {
const pool = await DbPool.connect(process.env.DATABASE_URL!);
await use(pool);
await pool.close();
},
{ scope: 'worker' },
],
// test fixture — depend dbPool (worker) → OK
pageWithToken: async ({ page, dbPool }, use) => {
const token = await dbPool.query('SELECT token FROM test_tokens LIMIT 1');
await page.addInitScript((t) => {
localStorage.setItem('auth_token', t);
}, token.rows[0].token);
await use();
},
});
Test fixture depend worker fixture — reuse instance
Khi test fixture depend worker fixture, test fixture nhận được cùng instance của worker fixture đang được dùng bởi worker đó. Không có instance mới nào được tạo — đây chính là mục đích của worker scope: tái dùng resource nặng (DB pool, browser instance, WS server).
// worker-scope: dbPool tạo 1 lần per worker
// 10 test trong cùng worker đều nhận chung 1 dbPool instance
Shared Dependency
Khi nhiều fixture cùng depend một fixture, instance của dependency được tạo bao nhiêu lần?
Câu trả lời phụ thuộc scope của dependency:
- Nếu dependency là test scope: tạo 1 lần per test, tất cả fixture trong test đó dùng chung instance đó.
- Nếu dependency là worker scope: tạo 1 lần per worker, tất cả test trong worker dùng chung.
export const test = base.extend<{
apiClient: APIClient;
fixtureA: DataA;
fixtureB: DataB;
}>({
// test-scope
apiClient: async ({ request, baseURL }, use) => {
const client = new APIClient(request, baseURL ?? '');
await use(client);
},
// fixtureA depend apiClient
fixtureA: async ({ apiClient }, use) => {
const data = await apiClient.fetchA();
await use(data);
},
// fixtureB cũng depend apiClient
fixtureB: async ({ apiClient }, use) => {
const data = await apiClient.fetchB();
await use(data);
},
});
Trong một test dùng cả fixtureA và fixtureB, apiClient chỉ được khởi tạo 1 lần — fixtureA và fixtureB nhận cùng instance. Cleanup apiClient cũng chỉ chạy 1 lần sau khi cả hai fixture đã cleanup xong.
test('A và B dùng chung apiClient', async ({ fixtureA, fixtureB }) => {
// apiClient: 1 instance duy nhất
// fixtureA và fixtureB đều đã được khởi tạo xong
console.log(fixtureA, fixtureB);
});
Đây là hành vi quan trọng khi thiết kế fixture: nếu fixture setup side effect trên shared dependency (ví dụ thêm header vào client), tất cả fixture khác dùng cùng dependency đó sẽ thấy thay đổi đó.
Override Fixture Với Dependency
Khi override built-in fixture, fixture mới vẫn có thể nhận built-in fixture gốc trong dependency — tức là nhận instance đã được tạo bởi Playwright trước khi override chạy.
// Override built-in page: inject thêm init script vào mỗi page
export const test = base.extend<{ page: Page }>({
page: async ({ page, context }, use) => {
// page ở đây là built-in page đã được tạo sẵn
// context là BrowserContext của page đó
await page.addInitScript(() => {
// Inject mock feature flags vào window
(window as any).__FLAGS__ = { newCheckout: true, betaDashboard: false };
});
// Truyền cùng page instance xuống test
await use(page);
},
});
Playwright cho phép override fixture nhận chính fixture đó làm dependency — không bị circular dependency vì Playwright hiểu đây là override chain: built-in page → override page.
Override với thêm behavior từ context
export const test = base.extend<{
page: Page;
authedPage: Page;
}>({
// Override page: thêm init script cho tất cả test
page: async ({ page }, use) => {
await page.addInitScript(() => {
(window as any).__TEST_ENV__ = true;
});
await use(page);
},
// authedPage depend page (đã được override ở trên)
// → authedPage tự động nhận page có __TEST_ENV__ = true
authedPage: async ({ page }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await use(page);
},
});
Dependency chain ở đây: built-in page → override page (thêm init script) → authedPage. Test dùng authedPage sẽ nhận page đã có cả init script lẫn login state.
Circular Dependency
Circular dependency xảy ra khi fixture A depend fixture B, và fixture B lại depend fixture A. Playwright detect pattern này và throw error tại runtime.
// LỖI: circular dependency
export const test = base.extend<{
fixtureA: string;
fixtureB: string;
}>({
// fixtureA depend fixtureB
fixtureA: async ({ fixtureB }, use) => {
await use(`A:${fixtureB}`);
},
// fixtureB depend fixtureA → CIRCULAR
fixtureB: async ({ fixtureA }, use) => {
await use(`B:${fixtureA}`);
},
});
Error message runtime:
Error: Fixtures "fixtureA" and "fixtureB" are circular.
Circular dependency thường là dấu hiệu của thiết kế fixture có vấn đề — hai fixture đang cố gắng làm công việc lẽ ra nên được tách vào fixture thứ ba:
// Giải pháp: tách phần chung vào fixture riêng
export const test = base.extend<{
sharedData: SharedData; // fixture trung gian
fixtureA: string;
fixtureB: string;
}>({
sharedData: async ({}, use) => {
await use({ value: 'shared' });
},
fixtureA: async ({ sharedData }, use) => {
await use(`A:${sharedData.value}`);
},
fixtureB: async ({ sharedData }, use) => {
await use(`B:${sharedData.value}`);
},
});
testInfo Và workerInfo Inject
testInfo và workerInfo không phải fixture — chúng được inject vào fixture function qua tham số thứ ba (không phải destructuring từ tham số đầu như fixture thông thường).
import { test as base, TestInfo } from '@playwright/test';
export const test = base.extend<{
tempFile: string;
workerLog: void;
}>({
// testInfo là tham số thứ 3 (sau fixtures và use)
tempFile: async ({ }, use, testInfo) => {
// testInfo.title: tên test đang chạy
// testInfo.outputDir: thư mục output per-test
const filePath = `${testInfo.outputDir}/temp-data.json`;
await use(filePath);
// Cleanup: không cần xoá — outputDir được Playwright quản lý
},
});
Với worker-scope fixture, tham số thứ ba là workerInfo:
export const test = base.extend<
{},
{ workerLog: void }
>({
workerLog: [
async ({}, use, workerInfo) => {
// workerInfo.workerIndex: index của worker (0, 1, 2, ...)
// workerInfo.parallelIndex: index trong parallel run
console.log(`Worker ${workerInfo.workerIndex} started`);
await use();
console.log(`Worker ${workerInfo.workerIndex} done`);
},
{ scope: 'worker' },
],
});
Điểm quan trọng: testInfo KHÔNG inject được vào worker-scope fixture — test chưa tồn tại khi worker khởi tạo. TypeScript sẽ báo lỗi nếu dùng sai.
Các property testInfo thường dùng trong fixture
testInfo.title— tên test, dùng để đặt tên file log, screenshottestInfo.outputDir— thư mục riêng per-test cho artifactstestInfo.retry— số lần retry hiện tại (0 = lần đầu)testInfo.annotations— mảng annotation, có thể push thêm từ fixturetestInfo.attach(name, options)— đính file vào test report từ fixture
Pattern Fixture Composition
Với project lớn, fixture thường được tổ chức theo layer. Mỗi layer chỉ depend layer dưới — không depend lên hoặc skip layer.
import { test as base, Page } from '@playwright/test';
class APIClient { /* ... */ }
type TestFixtures = {
// Layer 1: Data
testUser: { id: string; email: string };
testOrder: { id: string; total: number };
// Layer 2: UI
authedPage: Page;
// Layer 3: Page Objects
dashboardPage: Page;
ordersPage: Page;
};
export const test = base.extend<TestFixtures>({
// ─── Layer 1: Data ───────────────────────────────
testUser: async ({ request }, use) => {
const res = await request.post('/api/test/users');
const user = await res.json();
await use(user);
await request.delete(`/api/test/users/${user.id}`);
},
testOrder: async ({ request, testUser }, use) => {
const res = await request.post('/api/test/orders', {
data: { userId: testUser.id, items: [{ sku: 'DEMO', qty: 1 }] },
});
const order = await res.json();
await use(order);
await request.delete(`/api/test/orders/${order.id}`);
},
// ─── Layer 2: UI ─────────────────────────────────
authedPage: async ({ page, testUser }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill(testUser.email);
await page.getByLabel('Password').fill('test-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await use(page);
},
// ─── Layer 3: Page Objects ────────────────────────
dashboardPage: async ({ authedPage }, use) => {
await authedPage.goto('/dashboard');
await use(authedPage);
},
ordersPage: async ({ authedPage }, use) => {
await authedPage.goto('/orders');
await use(authedPage);
},
});
Test sử dụng fixture theo nhu cầu — không cần biết dependency chain bên dưới:
test('dashboard hiện đúng tên user', async ({ dashboardPage, testUser }) => {
await expect(dashboardPage.getByText(testUser.email)).toBeVisible();
});
test('orders page có order vừa tạo', async ({ ordersPage, testOrder }) => {
await expect(ordersPage.getByText(testOrder.id)).toBeVisible();
});
// Test này dùng cả ordersPage lẫn testOrder
// → testUser, authedPage, ordersPage, testOrder đều được tạo
// → testUser chỉ tạo 1 lần dù cả testOrder và authedPage đều depend nó
Tách fixture ra nhiều file
Trong project thực tế, fixture theo layer thường được tách file rồi merge:
// fixtures/data.ts — layer 1
export const dataTest = base.extend<DataFixtures>({ testUser: ..., testOrder: ... });
// fixtures/ui.ts — layer 2, extend từ dataTest
import { dataTest } from './data';
export const uiTest = dataTest.extend<UIFixtures>({ authedPage: ... });
// fixtures/pages.ts — layer 3, extend từ uiTest
import { uiTest } from './ui';
export const test = uiTest.extend<PageFixtures>({ dashboardPage: ..., ordersPage: ... });
Mỗi layer extend từ layer dưới — dependency chain được giữ nguyên mà không cần mergeTests() (dùng mergeTests khi cần kết hợp fixture từ nhiều nhánh độc lập).
Limitation
- Debug khó khi chain dài. Khi fixture ở layer 3 fail vì lý do từ layer 1, stack trace thường trỏ vào layer 3. Cần mở trace viewer hoặc thêm log vào từng fixture để xác định tầng nào fail.
- Không có dynamic dependency. Dependency phải khai báo tĩnh trong destructuring — không thể decide fixture nào cần depend dựa trên runtime condition. Nếu fixture chỉ thỉnh thoảng cần dependency, vẫn phải khai báo dependency đó; Playwright luôn khởi tạo nó.
- Fixture không thể depend fixture chưa được khai báo trong generic. Nếu TypeScript type không liệt kê fixture, không destructure được — compiler báo lỗi ngay.
- Thứ tự giữa hai fixture không depend nhau là không xác định. Nếu logic cần A được tạo trước B trong cùng layer, phải thêm dependency tường minh thay vì dựa vào thứ tự khai báo trong object.
Pitfall Thường Gặp
Pitfall 1 — Depend fixture không khai báo trong generic
// LỖI: apiClient không được khai báo trong generic type
export const test = base.extend<{ authedPage: Page }>({
authedPage: async ({ page, apiClient }, use) => {
// ^^^^^^^^^
// TypeScript error: Property 'apiClient' does not exist on type...
await use(page);
},
});
Sửa: thêm apiClient: APIClient vào generic type.
Pitfall 2 — Worker fixture depend test fixture
// LỖI: worker fixture depend test fixture
export const test = base.extend<
{ authedPage: Page },
{ sharedBrowser: Browser }
>({
sharedBrowser: [
async ({ authedPage }, use) => {
// ^^^^^^^^^^
// Runtime error: worker-scoped fixture "sharedBrowser"
// cannot use test-scoped fixture "authedPage"
await use(authedPage.context().browser()!);
},
{ scope: 'worker' },
],
authedPage: async ({ page }, use) => { await use(page); },
});
Sửa: chuyển sharedBrowser sang test scope, hoặc chuyển authedPage sang worker scope (nếu logic cho phép).
Pitfall 3 — Circular dependency ngầm
// Trông bình thường nhưng circular:
// userSession depend authToken
// authToken depend userSession
export const test = base.extend<{
authToken: string;
userSession: Session;
}>({
authToken: async ({ userSession }, use) => {
await use(userSession.token);
},
userSession: async ({ authToken }, use) => {
const session = await Session.fromToken(authToken);
await use(session);
},
});
// → Runtime error: Fixtures "authToken" and "userSession" are circular.
Trong trường hợp này, authToken và userSession là cùng một thứ — chọn một, bỏ một, hoặc tách phần login ra fixture riêng (credentials) rồi cả hai cùng depend credentials.
Pitfall 4 — Over-engineering dependency chain
// Quá nhiều layer cho test đơn giản
// Fixture chain: config → httpClient → sessionManager →
// tokenCache → authState → authedPage → adminPage → dashboardPage
// Để viết 1 test: expect(dashboardPage.getByText('Hi')).toBeVisible()
Nếu test chỉ cần check 1 element trên dashboard, chain 7 layer là over-engineering. Mỗi layer thêm overhead setup/teardown và điểm fail tiềm ẩn. Rule of thumb: không quá 3-4 layer. Nếu chain dài hơn, xem lại xem layer nào có thể merge hoặc đơn giản hoá.
Quiz
Câu 1. Fixture authedPage khai báo: async ({ page, testUser }, use) => { ... }. Playwright xác định dependency của fixture này bằng cách nào?
Đáp án
Playwright đọc tên property trong destructuring tham số đầu — page và testUser — để xác định fixture nào cần được khởi tạo trước authedPage. Không phải qua phân tích runtime mà qua phân tích static khi build dependency graph.
Câu 2. Test A dùng fixtureA và fixtureB, cả hai đều depend apiClient (test scope). Bao nhiêu instance apiClient được tạo cho test A?
Đáp án
1 instance. Trong một test, mỗi test-scope fixture chỉ được tạo một lần, dù có bao nhiêu fixture khác depend vào nó. fixtureA và fixtureB dùng chung cùng một instance apiClient.
Câu 3. Worker fixture dbPool có thể depend test fixture authedPage không? Tại sao?
Đáp án
Không. Worker fixture được khởi tạo một lần per worker trước khi bất kỳ test nào chạy — tại thời điểm đó chưa có test nào, nên không có test fixture nào tồn tại để inject. Playwright throw runtime error nếu cấu hình như vậy.
Câu 4. Trong fixture function, testInfo được inject bằng cách nào — destructuring từ tham số đầu hay tham số riêng?
Đáp án
Tham số riêng — tham số thứ ba của fixture function: async ({ page }, use, testInfo) => { ... }. Không destructure từ tham số đầu như fixture thông thường. workerInfo cũng tương tự nhưng dùng cho worker-scope fixture.
Câu 5. Fixture A depend B, B depend A. Playwright phát hiện vấn đề này lúc nào — compile time hay runtime?
Đáp án
Runtime — Playwright build dependency graph khi bắt đầu chạy test và detect circular dependency tại thời điểm đó. TypeScript không phát hiện được circular fixture dependency ở compile time vì dependency được xác định qua destructuring tên property.
Bài Tiếp Theo
Bài 28: Fixture Cleanup Sau use() — code sau await use() chạy như thế nào, thứ tự cleanup giữa các fixture phụ thuộc nhau, và các pattern teardown an toàn.
