Danh sách bài viết

Bài 67: testInfo.workerIndex & parallelIndex

Bài 67 đào sâu hai field trên đối tượng testInfo — workerIndex và parallelIndex — cách dùng trong test, fixture, hook, sự khác biệt hành vi khi có retry, và các pattern tách resource theo worker trong suite chạy song song.

28/05/2026
0 lượt xem
1

Mục Tiêu Bài Học

Sau bài này, bạn sẽ:

  • Truy cập testInfo.workerIndextestInfo.parallelIndex trong 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 workerInfo trong 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 parallelIndex và khi nào dùng workerIndex.
  • 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.workerIndextestInfo.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.

2

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

3

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

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ới parallelIndex.
  • parallelIndex: 0, 1, 2, 3 — max là workers - 1 = 3.
5

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 workerIndex tiếp theo trong dãy tăng dần.
  • parallelIndex giữ 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 parallelIndex khi 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 workerIndex khi 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.
6

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. workerInfoworkerIndexparallelIndex 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;
}
7

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:

  • workerIndex một mình không đủ — hai test khác nhau chạy tuần tự trong cùng worker sẽ có cùng workerIndex.
  • 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.

8

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_0 vẫ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();
  }
}
9

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

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 >= max parallelIndex + 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.
11

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 afterEachafterAll.
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})`
    );
  }
});
12

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

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

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.workerIndextestInfo.parallelIndex của test A là bao nhiêu?

Đáp án

Cả hai đều là 0. Khi không có retry, workerIndexparallelIndex 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, workerIndexparallelIndex 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.

15

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.