Mục lục
- Mục Tiêu Bài Học
- testInfo Là Gì?
- Cú Pháp Truy Cập
- Type & Giá Trị
- workerIndex vs parallelIndex — Khác Biệt Khi Retry
- workerInfo Trong Worker-Scope Fixture
- Use Case: Unique Data Per Test
- Use Case: DB Schema Per Worker
- Use Case: Port Per Worker
- Pattern: Account Pool
- Các Field Hữu Ích Khác Của testInfo
- Pattern: Annotation Worker
- Pitfalls
- Quiz
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau bài này, bạn sẽ:
- Truy cập
testInfo.workerIndexvàtestInfo.parallelIndextrong test, fixture, hook. - Phân biệt hai index: hành vi khi không có retry và khi test bị retry sinh worker mới.
- Dùng
workerInfotrong worker-scope fixture để setup resource chia sẻ theo worker. - Áp dụng bốn pattern thực tế: unique data, DB schema isolation, port per worker, account pool.
- Biết khi nào dùng
parallelIndexvà khi nào dùngworkerIndex. - Tránh 5 pitfall phổ biến khi dùng worker index trong parallel suite.
Phạm vi: Bài này tập trung vào testInfo.workerIndex và testInfo.parallelIndex — structured API trong runtime của test. Cách đọc worker index qua biến môi trường (PLAYWRIGHT_WORKER_INDEX) đã được đề cập ở bài 66. Worker-scope state sharing sẽ được đào sâu ở bài 68.
testInfo Là Gì?
testInfo là đối tượng do Playwright truyền vào làm tham số thứ hai của test callback (hoặc tham số thứ ba của fixture factory). Nó cung cấp metadata về test đang chạy, bao gồm vị trí file, retry count, timeout còn lại, annotation, và thông tin về worker.
Khác với env vars ở bài 66:
| testInfo.workerIndex | process.env.PLAYWRIGHT_WORKER_INDEX | |
|---|---|---|
| Kiểu dữ liệu | number |
string (cần parseInt) |
| Thời điểm khả dụng | Runtime — trong test / fixture / hook | Ngay khi worker process khởi động, kể cả trước test |
| Nguồn gốc | Playwright Test Runner API | Biến môi trường Node.js process |
| Type-safe | Có — TypeScript interface TestInfo |
Không — cần tự cast |
Với code TypeScript, testInfo là cách được khuyến nghị khi bạn cần worker index trong context của một test cụ thể.
Cú Pháp Truy Cập
Trong test callback:
import { test, expect } from '@playwright/test';
test('test', async ({ page }, testInfo) => {
console.log(testInfo.workerIndex); // 0, 1, 2...
console.log(testInfo.parallelIndex); // 0, 1, 2...
});
Tham số thứ hai của test callback là testInfo — tên biến có thể đặt tùy ý, nhưng testInfo là convention chuẩn theo docs.
Trong beforeEach / afterEach hook:
test.beforeEach(async ({}, testInfo) => {
const workerIdx = testInfo.workerIndex;
console.log(`[Worker ${workerIdx}] Chuẩn bị test: ${testInfo.title}`);
});
test.afterEach(async ({}, testInfo) => {
if (testInfo.status === 'failed') {
console.log(`[Worker ${testInfo.workerIndex}] FAIL: ${testInfo.title}`);
}
});
Trong fixture test-scope:
// fixtures.ts
import { test as base } from '@playwright/test';
type MyFixtures = {
workerEmail: string;
};
export const test = base.extend<MyFixtures>({
workerEmail: async ({}, use, testInfo) => {
const idx = testInfo.workerIndex;
// Tạo email unique cho mỗi worker
const email = `worker-${idx}-${Date.now()}@test.example`;
await use(email);
},
});
Trong beforeAll / afterAll:
test.beforeAll(async ({}, testInfo) => {
// testInfo ở đây là info của test đầu tiên trong group
// workerIndex phản ánh worker đang chạy group này
console.log(`Worker ${testInfo.workerIndex} khởi tạo beforeAll`);
});
Type & Giá Trị
Cả hai field đều có type number, 0-based:
interface TestInfo {
// ...
readonly workerIndex: number; // index của worker process
readonly parallelIndex: number; // index của "slot" trong pool
// ...
}
| Field | Type | Giá trị tối thiểu | Giá trị tối đa |
|---|---|---|---|
workerIndex |
number |
0 | Tổng số worker process đã spawn trong suốt run (tăng mỗi khi có retry) |
parallelIndex |
number |
0 | config workers - 1 (không vượt quá số slot) |
Ví dụ với workers: 4, không có retry:
workerIndex: 0, 1, 2, 3 — trùng vớiparallelIndex.parallelIndex: 0, 1, 2, 3 — max làworkers - 1 = 3.
workerIndex vs parallelIndex — Khác Biệt Khi Retry
Khi không có retry, hai index giống nhau. Sự khác biệt xuất hiện khi test fail và Playwright phải retry:
Kịch bản: workers: 4, retries: 2. Test X chạy trên worker 0 (slot 0) lần đầu, fail. Playwright spawn worker mới để retry.
Lần chạy đầu (attempt 1):
Worker process #0 (workerIndex=0, parallelIndex=0) → Test X → FAIL
Retry lần 1 (attempt 2):
Worker process #4 (workerIndex=4, parallelIndex=0) → Test X → FAIL
↑ Worker mới được spawn (index 4)
↑ Nhưng vẫn dùng slot 0 → parallelIndex=0
Retry lần 2 (attempt 3):
Worker process #5 (workerIndex=5, parallelIndex=0) → Test X → PASS
↑ Worker mới nữa (index 5)
↑ Slot không đổi → parallelIndex=0
Tại sao lại thiết kế như vậy:
- Khi retry, Playwright tắt worker cũ và khởi tạo worker mới để có môi trường sạch. Worker mới nhận
workerIndextiếp theo trong dãy tăng dần. parallelIndexgiữ nguyên slot ban đầu để đảm bảo resource gắn với slot (DB schema, port) vẫn nhất quán qua các lần retry.
Quy tắc chọn field:
- Dùng
parallelIndexkhi pin resource tĩnh (DB schema, port, pre-created account) — slot không thay đổi qua retry, resource luôn nhất quán. - Dùng
workerIndexkhi cần phân biệt worker process generation — mỗi worker mới nhận index mới, hữu ích để trace log hoặc tạo data dynamic không cần tái sử dụng.
workerInfo Trong Worker-Scope Fixture
Với fixture có scope: 'worker', Playwright truyền workerInfo (không phải testInfo) làm tham số thứ ba. workerInfo có workerIndex và parallelIndex tương tự testInfo, nhưng không có các field test-specific như title, retry, annotations.
// fixtures.ts
import { test as base } from '@playwright/test';
import type { TestInfo, WorkerInfo } from '@playwright/test';
type WorkerFixtures = {
dbConnection: DatabaseClient;
};
export const test = base.extend<{}, WorkerFixtures>({
dbConnection: [
async ({}, use, workerInfo) => {
// workerInfo.workerIndex: worker process index
// workerInfo.parallelIndex: slot index (ổn định qua retry)
const idx = workerInfo.parallelIndex; // dùng parallelIndex để pin schema
const client = await connectDB(`test_worker_${idx}`);
await use(client);
await client.close();
},
{ scope: 'worker' },
],
});
Worker-scope fixture chạy một lần per worker, không phải per test. Do đó workerInfo không chứa thông tin test cụ thể — chỉ chứa thông tin về worker đó. Chi tiết về worker-scope state sharing và lifecycle thuộc bài 68.
WorkerInfo interface (tóm tắt):
interface WorkerInfo {
readonly workerIndex: number;
readonly parallelIndex: number;
readonly project: FullProjectConfig;
readonly config: FullConfig;
}
Use Case: Unique Data Per Test
Khi nhiều test chạy song song và mỗi test cần dữ liệu riêng (email, username, ID), dùng workerIndex kết hợp timestamp để tạo data unique:
test('đăng ký tài khoản mới', async ({ page }, testInfo) => {
const workerIdx = testInfo.workerIndex;
// workerIndex đảm bảo không trùng giữa các worker chạy song song
// Date.now() đảm bảo không trùng khi cùng worker chạy nhiều test liên tiếp
const email = `user-${workerIdx}-${Date.now()}@test.example`;
const username = `user_w${workerIdx}_${Date.now()}`;
await page.goto('/register');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Username').fill(username);
await page.getByRole('button', { name: 'Đăng ký' }).click();
await expect(page.getByText('Đăng ký thành công')).toBeVisible();
});
Tại sao cần cả workerIndex lẫn timestamp:
workerIndexmột mình không đủ — hai test khác nhau chạy tuần tự trong cùng worker sẽ có cùngworkerIndex.Date.now()một mình không đủ — hai worker chạy song song có thể đọc timestamp gần nhau trong cùng millisecond.- Kết hợp
worker-${workerIdx}-${Date.now()}đảm bảo uniqueness trong cả hai chiều.
Nếu cần ID ngắn hơn, dùng parallelIndex (tối đa workers-1) thay vì workerIndex — giá trị nhỏ hơn, dễ đọc trong log.
Use Case: DB Schema Per Worker
Khi test cần DB isolation hoàn toàn (mỗi worker không được ảnh hưởng data của worker khác), pattern phổ biến là tạo một DB schema riêng cho mỗi slot:
// fixtures.ts
import { test as base } from '@playwright/test';
type WorkerFixtures = {
db: DatabaseClient;
};
export const test = base.extend<{}, WorkerFixtures>({
db: [
async ({}, use, workerInfo) => {
// Dùng parallelIndex để pin schema theo slot
// Nếu dùng workerIndex, retry sẽ tạo schema mới → schema cũ bị bỏ lại
const schemaName = `test_worker_${workerInfo.parallelIndex}`;
const client = await connectDB({ schema: schemaName });
await client.migrate(); // áp dụng migration cho schema này
await use(client);
await client.truncateAll(); // dọn data sau mỗi worker run
await client.close();
},
{ scope: 'worker' },
],
});
Tại sao dùng parallelIndex thay vì workerIndex:
Nếu dùng workerIndex cho tên schema và test fail + retry:
- Lần 1: worker 0 → schema
test_worker_0. - Retry: worker 4 → schema
test_worker_4(mới). Schema cũtest_worker_0vẫn còn nhưng không được dọn. - Nhiều lần retry → nhiều schema orphan tích lũy.
Với parallelIndex: retry luôn quay về slot 0 → cùng schema test_worker_0 → migration không cần chạy lại, data được truncate và tái sử dụng.
Setup global schema trước khi run:
// global-setup.ts
import { connectDB } from './helpers/db';
export default async function globalSetup() {
const workerCount = parseInt(process.env.PLAYWRIGHT_WORKERS || '4', 10);
for (let i = 0; i < workerCount; i++) {
const client = await connectDB({ schema: `test_worker_${i}` });
await client.createSchemaIfNotExists();
await client.migrate();
await client.close();
}
}
Use Case: Port Per Worker
Khi test cần khởi động một mock server hoặc dev server, nhiều worker không thể bind cùng một port. Dùng parallelIndex để tính port riêng cho mỗi slot:
// fixtures.ts
import { test as base } from '@playwright/test';
import { createServer } from './helpers/mock-server';
type WorkerFixtures = {
mockServer: { url: string; port: number };
};
export const test = base.extend<{}, WorkerFixtures>({
mockServer: [
async ({}, use, workerInfo) => {
// Mỗi slot có port riêng — không conflict khi parallel
const port = 3000 + workerInfo.parallelIndex;
const server = createServer({ port });
await server.start();
await use({ url: `http://localhost:${port}`, port });
await server.stop();
},
{ scope: 'worker' },
],
});
// test sử dụng fixture
test('mock API response', async ({ page, mockServer }) => {
mockServer.registerRoute('/api/users', [{ id: 1, name: 'Test' }]);
await page.goto(`${mockServer.url}/dashboard`);
await expect(page.getByText('Test')).toBeVisible();
});
Chọn range port an toàn:
- Base port 3000–4000 thường trống trên máy dev và CI.
- Với
workers: 4, range là 3000–3003 — không chồng lấp. - Tránh dùng port dưới 1024 (privileged) và các port well-known như 5432 (Postgres), 6379 (Redis).
- Kiểm tra port không trùng app dev server đang chạy: nếu app dev chạy ở 3000, dùng base port 4000 trở lên.
Pattern: Account Pool
Khi test cần login với tài khoản thực (không mock), cách phổ biến là chuẩn bị sẵn N tài khoản test — mỗi worker dùng 1 tài khoản theo parallelIndex. Điều này tránh tình huống nhiều worker login cùng một tài khoản gây conflict session.
// fixtures.ts
const accounts = [
{ email: '[email protected]', password: 'pass_user1' },
{ email: '[email protected]', password: 'pass_user2' },
{ email: '[email protected]', password: 'pass_user3' },
{ email: '[email protected]', password: 'pass_user4' },
];
test('luồng đặt hàng', async ({ page }, testInfo) => {
// parallelIndex đảm bảo mỗi slot dùng account riêng
// Ổn định qua retry — slot 0 luôn dùng [email protected]
const acc = accounts[testInfo.parallelIndex];
await page.goto('/login');
await page.getByLabel('Email').fill(acc.email);
await page.getByLabel('Password').fill(acc.password);
await page.getByRole('button', { name: 'Đăng nhập' }).click();
await expect(page.getByText('Xin chào')).toBeVisible();
// Tiếp tục flow test...
});
Tối ưu với worker-scope fixture:
Nếu mỗi test trong cùng worker đều cần login với cùng account, login một lần ở worker-scope fixture và tái sử dụng storageState:
type WorkerFixtures = {
workerAccount: { email: string; storageState: string };
};
export const test = base.extend<{}, WorkerFixtures>({
workerAccount: [
async ({ browser }, use, workerInfo) => {
const acc = accounts[workerInfo.parallelIndex];
const authFile = `playwright/.auth/worker-${workerInfo.parallelIndex}.json`;
// Login một lần, lưu state
const page = await browser.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill(acc.email);
await page.getByLabel('Password').fill(acc.password);
await page.getByRole('button', { name: 'Đăng nhập' }).click();
await page.context().storageState({ path: authFile });
await page.close();
await use({ email: acc.email, storageState: authFile });
},
{ scope: 'worker' },
],
});
Yêu cầu khi dùng pattern này:
- Số tài khoản trong pool phải >=
workers(tức >= maxparallelIndex+ 1). - Các tài khoản phải không có ràng buộc unique session — app không được kick session cũ khi login từ nơi khác (hoặc mỗi test phải logout trước khi kết thúc).
- Data của các tài khoản nên độc lập — actions của worker 0 không ảnh hưởng dữ liệu của worker 1.
Các Field Hữu Ích Khác Của testInfo
Ngoài hai field chính của bài này, testInfo còn cung cấp nhiều field có ích trong fixture và hook:
| Field | Type | Mô tả |
|---|---|---|
testInfo.title |
string |
Tên test (chuỗi truyền vào test('...')). |
testInfo.retry |
number |
Số lần retry hiện tại — 0 ở lần chạy đầu, 1 ở retry thứ nhất. |
testInfo.status |
'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted' |
Kết quả test — chỉ có giá trị trong afterEach và afterAll. |
testInfo.project |
FullProjectConfig |
Config của project đang chạy (browser, viewport, baseURL, v.v.). |
testInfo.config |
FullConfig |
Toàn bộ config của Playwright — bao gồm tất cả project. |
testInfo.annotations |
Array<{type, description}> |
Mảng annotation có thể ghi thêm vào — xuất hiện trong report. |
testInfo.outputDir |
string |
Thư mục output riêng cho test này — screenshot, trace sẽ lưu ở đây. |
testInfo.snapshotDir |
string |
Thư mục chứa snapshot baseline cho test này. |
Ví dụ kết hợp retry với workerIndex:
test.beforeEach(async ({}, testInfo) => {
if (testInfo.retry > 0) {
console.log(
`Retry #${testInfo.retry} cho "${testInfo.title}" ` +
`trên worker ${testInfo.workerIndex} (slot ${testInfo.parallelIndex})`
);
}
});
Pattern: Annotation Worker
Ghi worker index vào annotation để dễ debug khi xem HTML report — biết test nào chạy trên worker nào:
test.beforeEach(async ({}, testInfo) => {
testInfo.annotations.push({
type: 'worker',
description: `${testInfo.workerIndex} (slot ${testInfo.parallelIndex})`,
});
});
Annotation này xuất hiện trong HTML report ở phần chi tiết của từng test. Nó giúp:
- Phát hiện test có ordering dependency — nếu test luôn fail khi chạy trên worker 2 nhưng pass trên worker 0, có thể có shared state issue.
- Debug account pool — xác nhận mỗi worker dùng đúng account slot.
- Trace lại khi log DB có lỗi — biết schema nào (slot nào) bị ảnh hưởng.
Annotation tùy chỉnh thêm thông tin test data:
test('checkout flow', async ({ page }, testInfo) => {
const acc = accounts[testInfo.parallelIndex];
// Ghi vào annotation để dễ trace khi test fail
testInfo.annotations.push({
type: 'account',
description: acc.email,
});
// ... phần còn lại của test
});
Pitfalls
Pitfall 1: Hardcode index 0 thay vì dùng workerIndex/parallelIndex
// Sai: port 3000 cố định → tất cả worker conflict
test('server test', async ({ page }) => {
await page.goto('http://localhost:3000');
});
// Đúng: mỗi worker dùng port riêng theo slot
test('server test', async ({ page }, testInfo) => {
const port = 3000 + testInfo.parallelIndex;
await page.goto(`http://localhost:${port}`);
});
Pitfall 2: Nhầm workerIndex với parallelIndex cho static resource
// Sai: dùng workerIndex cho DB schema
// → retry sinh workerIndex mới → schema khác → orphan schema tích lũy
async ({}, use, workerInfo) => {
const schema = `test_worker_${workerInfo.workerIndex}`; // SAI
...
}
// Đúng: dùng parallelIndex cho static resource
async ({}, use, workerInfo) => {
const schema = `test_worker_${workerInfo.parallelIndex}`; // ĐÚNG
...
}
Pitfall 3: Account pool nhỏ hơn số worker
// accounts chỉ có 3 phần tử, workers: 4
// → parallelIndex có thể là 3 → accounts[3] = undefined → crash
const accounts = [
{ email: '[email protected]', password: 'p1' },
{ email: '[email protected]', password: 'p2' },
{ email: '[email protected]', password: 'p3' },
// Thiếu user4!
];
test('login', async ({ page }, testInfo) => {
const acc = accounts[testInfo.parallelIndex]; // undefined nếu parallelIndex=3
await page.getByLabel('Email').fill(acc.email); // TypeError: Cannot read 'email' of undefined
});
Luôn đảm bảo accounts.length >= workers. Thêm runtime check nếu cần:
const acc = accounts[testInfo.parallelIndex];
if (!acc) throw new Error(
`Account pool (${accounts.length}) nhỏ hơn workers. parallelIndex=${testInfo.parallelIndex}`
);
Pitfall 4: Dùng testInfo ngoài test/fixture/hook
// Sai: testInfo không khả dụng ở module level
// import { testInfo } from '@playwright/test'; // KHÔNG tồn tại
// Sai: cố gắng lấy testInfo từ config
export default defineConfig({
use: {
baseURL: `http://localhost:${3000 + ???}`, // Không có testInfo ở đây
},
});
// Đúng: đọc từ fixture, dùng trong test
test('...', async ({ page }, testInfo) => {
const baseURL = `http://localhost:${3000 + testInfo.parallelIndex}`;
await page.goto(baseURL);
});
testInfo chỉ khả dụng trong runtime context của test — không thể dùng ở config load time hay module initialization.
Pitfall 5: Dùng workerIndex để name artifact mà không include testInfo.title
// Dễ bị trùng: nhiều test trong cùng worker → cùng tên file
test.afterEach(async ({}, testInfo) => {
if (testInfo.status === 'failed') {
await page.screenshot({
path: `screenshots/worker-${testInfo.workerIndex}.png` // Ghi đè nhau!
});
}
});
// Đúng: include title hoặc dùng testInfo.outputDir
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status === 'failed') {
const safeName = testInfo.title.replace(/[^a-z0-9]/gi, '_');
await page.screenshot({
path: `${testInfo.outputDir}/fail-${safeName}.png`
});
}
});
Quiz
Câu 1. Config workers: 4, không có retry. Test A chạy trên worker process đầu tiên được spawn. testInfo.workerIndex và testInfo.parallelIndex của test A là bao nhiêu?
Đáp án
Cả hai đều là 0. Khi không có retry, workerIndex và parallelIndex giống nhau — worker đầu tiên nhận index 0 cho cả hai field.
Câu 2. Config workers: 4, retries: 2. Test B chạy trên slot 1 (parallelIndex=1), fail hai lần, pass ở retry thứ 2. Đến lúc pass, workerIndex và parallelIndex là bao nhiêu?
Đáp án
parallelIndex = 1 (không thay đổi — giữ nguyên slot). workerIndex phụ thuộc vào tổng số worker đã spawn trước đó. Nếu ban đầu có 4 worker (index 0–3), retry 1 tạo worker index 4 (nhưng cho test B là worker 5 vì slot 1 lúc ban đầu là worker 1), retry 2 tạo worker mới tiếp theo. Giá trị cụ thể phụ thuộc vào thứ tự spawn, nhưng chắc chắn > 3 và khác index của lần fail trước.
Câu 3. Bạn dùng pattern account pool với 4 tài khoản và workers: 4. Test fail và retry sinh worker mới. Worker mới nhận parallelIndex nào, và tài khoản nào được dùng?
Đáp án
parallelIndex không thay đổi khi retry — worker mới vẫn kế thừa cùng slot với worker cũ. Nếu test đang ở slot 2 (parallelIndex=2), worker mới sau retry cũng có parallelIndex=2, do đó tài khoản index 2 (accounts[2]) vẫn được dùng. Đây là lý do dùng parallelIndex thay vì workerIndex cho account pool.
Câu 4. Fixture có scope: 'worker' nhận tham số thứ ba là gì? Nó khác testInfo ở điểm nào?
Đáp án
Tham số thứ ba là workerInfo (kiểu WorkerInfo). Khác với testInfo: workerInfo không có các field test-specific như title, retry, status, annotations, outputDir. Nó chỉ chứa workerIndex, parallelIndex, project, và config — thông tin ở cấp worker, không phải cấp test.
Câu 5. Code sau có bug không? Nếu có, bug là gì và cách sửa?
const DB_SCHEMAS = ['schema_a', 'schema_b', 'schema_c'];
export const test = base.extend<{}, { db: Client }>({
db: [
async ({}, use, workerInfo) => {
const schema = DB_SCHEMAS[workerInfo.workerIndex];
const client = await connectDB({ schema });
await use(client);
await client.close();
},
{ scope: 'worker' },
],
});
Đáp án
Có hai bug: (1) Dùng workerIndex thay vì parallelIndex — khi retry sinh worker mới (workerIndex >= 3), DB_SCHEMAS[workerInfo.workerIndex] sẽ là undefined vì array chỉ có 3 phần tử. (2) Ngay cả khi không retry, nếu workers: 4 thì workerIndex 3 vẫn hợp lệ nhưng array chỉ có index 0–2 → undefined. Sửa: đổi sang parallelIndex và đảm bảo DB_SCHEMAS.length >= workers.
Bài Tiếp Theo
Bài 68: Worker-Scope State Sharing — đào sâu cách fixture scope: 'worker' chia sẻ state giữa các test trong cùng worker, lifecycle setup/teardown, và khi nào nên (không nên) dùng worker-scope state.
