Mục lục
- Mục Tiêu Bài Học
- Vị Trí Của parallelIndex Trong Per-Worker Auth
- Tại Sao parallelIndex, Không Phải workerIndex
- Pattern Account Pool Chuẩn
- Sizing Account Pool
- Combine Với workerStorageState
- Chiến Lược 1: Pre-Created Static Account
- Chiến Lược 2: Dynamic Account Per Worker
- Chiến Lược 3: Email Alias
- Fail-Fast Guard
- Pitfalls
- Quiz
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau bài này, bạn sẽ:
- Giải thích được tại sao
parallelIndexlà key đúng để map worker → account, trong khiworkerIndexdẫn đến out-of-bounds khi có retry. - Build account pool fixture worker-scoped đúng chuẩn với fail-fast guard.
- Tính đúng kích thước pool theo cấu hình
workers. - Chọn chiến lược tạo account phù hợp: static, dynamic, hoặc email alias.
- Tránh 5 pitfall phổ biến khi dùng account pool trong parallel suite.
Phạm vi: Bài này đào sâu lý do chọn parallelIndex và các pattern account pool. Cơ chế per-worker auth (setup fixture, workerStorageState, global setup) đã được đề cập ở bài 104. API login và request fixture sẽ thuộc bài 106.
Vị Trí Của parallelIndex Trong Per-Worker Auth
Per-worker auth (bài 104) giải quyết vấn đề: nhiều worker chạy song song cần đăng nhập với các tài khoản khác nhau để tránh conflict session. Sơ đồ mapping:
Worker slot 0 → account[0] → playwright/.auth/worker-0.json
Worker slot 1 → account[1] → playwright/.auth/worker-1.json
Worker slot 2 → account[2] → playwright/.auth/worker-2.json
Worker slot 3 → account[3] → playwright/.auth/worker-3.json
Chìa khóa ở đây là "worker slot" — không phải worker process. Một slot (parallelIndex) tồn tại trong suốt run, còn worker process có thể bị thay thế khi test fail và retry. Nếu dùng sai index để map vào account[], toàn bộ pattern sẽ gãy khi có retry.
parallelIndex là index của slot: 0-based, giá trị tối đa bằng workers - 1, không thay đổi khi Playwright tái sử dụng slot với worker process mới.
Tại Sao parallelIndex, Không Phải workerIndex
Kịch bản: workers: 4, retries: 2, account pool có 4 phần tử (index 0–3). Test X chạy trên slot 0, fail hai lần.
Attempt 1:
Worker process #0 → workerIndex=0, parallelIndex=0 → Test X FAIL
Attempt 2 (retry 1):
Worker process #4 → workerIndex=4, parallelIndex=0 → Test X FAIL
↑ Playwright tắt worker #0, spawn worker #4 (index tiếp theo trong chuỗi tăng dần)
↑ Slot không đổi → parallelIndex vẫn là 0
Attempt 3 (retry 2):
Worker process #5 → workerIndex=5, parallelIndex=0 → Test X PASS
↑ workerIndex tiếp tục tăng
↑ parallelIndex vẫn là 0
Với parallelIndex làm key: account[0] được dùng ở cả ba lần. Nhất quán, đúng.
Với workerIndex làm key:
- Attempt 1: account[0] — OK.
- Attempt 2: account[4] —
undefined. Pool chỉ có 4 phần tử (0–3), index 4 out-of-bounds → crash. - Attempt 3: account[5] —
undefined→ crash.
Ngay cả khi không có retry, workerIndex cũng không phù hợp: khi có nhiều project hay suite chạy, Playwright có thể spawn worker với workerIndex lớn hơn workers - 1, trong khi parallelIndex luôn bị giới hạn trong khoảng [0, workers-1].
| Thuộc tính | parallelIndex |
workerIndex |
|---|---|---|
| Giá trị | 0 → workers-1 (cố định range) | 0 → tăng dần không giới hạn |
| Khi retry | Giữ nguyên slot | Tăng lên (worker mới) |
| Phù hợp cho account pool | Có — range khớp pool size | Không — vượt pool size khi retry |
| Phù hợp cho unique trace log | Không — nhiều worker share slot | Có — mỗi process có index riêng |
Pattern Account Pool Chuẩn
Fixture worker-scoped nhận workerInfo (không phải testInfo) làm tham số thứ ba. Đây là nơi đúng để khởi tạo account pool — chạy một lần per worker, không phải per test.
// fixtures/accounts.ts
import { test as base } from '@playwright/test';
const accounts = [
{ email: '[email protected]', password: 'p0' },
{ email: '[email protected]', password: 'p1' },
{ email: '[email protected]', password: 'p2' },
{ email: '[email protected]', password: 'p3' },
];
type Account = typeof accounts[0];
export const test = base.extend<{}, { account: Account }>({
account: [
async ({}, use, workerInfo) => {
const idx = workerInfo.parallelIndex;
if (idx >= accounts.length) {
throw new Error(
`Increase account pool. parallelIndex ${idx} >= pool size ${accounts.length}`
);
}
await use(accounts[idx]);
},
{ scope: 'worker' },
],
});
Fixture dùng scope: 'worker' để đảm bảo:
- Account được resolve một lần per worker — không gọi lookup mỗi test.
workerInfo.parallelIndexphản ánh đúng slot của worker đó trong suốt lifecycle.- Cleanup (nếu có) chỉ chạy khi worker kết thúc, không sau mỗi test.
Trong test, fixture account sử dụng như bình thường:
test('kiểm tra trang profile', async ({ page, account }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(account.email);
await page.getByLabel('Password').fill(account.password);
await page.getByRole('button', { name: 'Đăng nhập' }).click();
await expect(page.getByTestId('profile-email')).toHaveText(account.email);
});
Sizing Account Pool
Quy tắc: pool size phải >= max parallelIndex + 1, tức là >= số workers thực tế chạy.
Một số cấu hình thường gặp:
| Cấu hình workers | Số workers thực tế | parallelIndex range | Pool size tối thiểu |
|---|---|---|---|
workers: 4 |
4 | 0–3 | 4 |
workers: '50%' trên 8 cores |
4 | 0–3 | 4 |
workers: 2 (CI) |
2 | 0–1 | 2 |
workers: 1 (debug) |
1 | 0 | 1 |
workers: '50%' phụ thuộc vào số logical CPU của máy — kết quả khác nhau giữa máy dev (thường 8–16 core) và CI (thường 2–4 core). Pool size nên được tính dựa trên môi trường worst-case (nhiều core nhất).
Thực hành an toàn: đặt pool size bằng giá trị workers cố định trong config, rồi đảm bảo pool tạo đủ account tương ứng. Nếu cần linh hoạt, đọc biến môi trường:
// Đọc workers từ env để validate pool size
const expectedWorkers = parseInt(process.env.PLAYWRIGHT_WORKERS ?? '4', 10);
if (accounts.length < expectedWorkers) {
throw new Error(
`Account pool (${accounts.length}) nhỏ hơn PLAYWRIGHT_WORKERS (${expectedWorkers})`
);
}
Đặt đoạn check này ở module level trong file fixtures — lỗi sẽ được phát hiện ngay khi workers khởi động, trước khi bất kỳ test nào chạy.
Combine Với workerStorageState
Account pool thường được dùng kết hợp với workerStorageState: mỗi worker login với account của slot mình, lưu storage state vào file riêng. Các test trong worker đó bắt đầu với context đã authenticated, không cần login lại.
// fixtures/auth.ts
import { test as base } from '@playwright/test';
import path from 'path';
const accounts = [
{ email: '[email protected]', password: 'p0' },
{ email: '[email protected]', password: 'p1' },
{ email: '[email protected]', password: 'p2' },
{ email: '[email protected]', password: 'p3' },
];
export const test = base.extend<{}, { workerStorageState: string }>({
workerStorageState: [
async ({ browser }, use, workerInfo) => {
const idx = workerInfo.parallelIndex;
if (idx >= accounts.length) {
throw new Error(
`No account for parallelIndex ${idx}. Pool size: ${accounts.length}`
);
}
const account = accounts[idx];
// File riêng cho mỗi slot — tránh conflict khi parallel ghi/đọc
const fileName = path.join('playwright', '.auth', `worker-${idx}.json`);
const page = await browser.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill(account.email);
await page.getByLabel('Password').fill(account.password);
await page.getByRole('button', { name: 'Đăng nhập' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: fileName });
await page.close();
await use(fileName);
},
{ scope: 'worker' },
],
// Override fixture page để inject storageState
page: async ({ browser, workerStorageState }, use) => {
const context = await browser.newContext({ storageState: workerStorageState });
const page = await context.newPage();
await use(page);
await context.close();
},
});
Điểm quan trọng:
fileNamebao gồmparallelIndex, không phảiworkerIndex. Khi retry, worker mới với cùng slot sẽ ghi đè file cũ — đây là hành vi mong muốn (login fresh).- Thư mục
playwright/.auth/phải được tạo trước hoặc dùngfs.mkdirSyncvớirecursive: true. - File này nên được
.gitignorevì chứa session token.
Chiến Lược 1: Pre-Created Static Account
Cách đơn giản nhất: hardcode một mảng account đã được tạo sẵn trong DB test.
// fixtures/accounts.ts
export const WORKER_ACCOUNTS = [
{ email: '[email protected]', password: 'TestPass0!', role: 'user' },
{ email: '[email protected]', password: 'TestPass1!', role: 'user' },
{ email: '[email protected]', password: 'TestPass2!', role: 'user' },
{ email: '[email protected]', password: 'TestPass3!', role: 'user' },
] as const;
Ưu điểm:
- Không tốn thời gian setup — lookup O(1) theo index.
- Dễ debug: biết chính xác account nào chạy test nào.
- Không phụ thuộc API tạo account.
Nhược điểm:
- Cần seed DB trước khi chạy — thường trong
globalSetuphoặc migration script. - Nếu test làm bẩn data của account (thay đổi profile, đổi password), run sau sẽ fail cho đến khi data được reset.
- Pool size phải được cập nhật thủ công khi tăng workers.
Phù hợp khi: môi trường test có DB được reset giữa các run (truncate + re-seed), hoặc khi test không thay đổi data gắn với account (chỉ đọc, checkout với product data độc lập).
Chiến Lược 2: Dynamic Account Per Worker
Tạo account mới qua API trong fixture setup, xóa sau khi worker kết thúc. Mỗi run có account hoàn toàn fresh, tránh state leak giữa các run.
// fixtures/dynamic-account.ts
import { test as base } from '@playwright/test';
type Account = { id: string; email: string; password: string };
export const test = base.extend<{}, { account: Account }>({
account: [
async ({}, use, workerInfo) => {
const idx = workerInfo.parallelIndex;
// Dùng parallelIndex trong email để dễ trace khi debug
// Date.now() đảm bảo unique giữa các run
const account = await createUserViaAPI({
email: `test-w${idx}-${Date.now()}@example.com`,
password: 'TestPass123!',
displayName: `Worker ${idx} Test User`,
});
await use(account);
// Cleanup sau khi worker kết thúc
await deleteUserViaAPI(account.id);
},
{ scope: 'worker' },
],
});
Ưu điểm:
- Mỗi run có account sạch — không cần lo state từ run trước.
- Pool size không cần khai báo trước — tạo đúng số lượng workers cần.
- Cleanup tự động.
Nhược điểm:
- Tốn API call trong setup của mỗi worker — làm chậm startup.
- Nếu worker crash trước cleanup, account orphan tồn tại trong DB.
- Phụ thuộc API endpoint tạo user — nếu endpoint lỗi, toàn bộ worker không chạy được.
Lưu ý khi dùng parallelIndex trong dynamic email: email chứa idx chỉ để dễ đọc trong log. Tính unique thực sự đến từ Date.now() — hai run khác nhau trên cùng slot sẽ tạo email khác nhau.
Chiến Lược 3: Email Alias
Gmail và Outlook hỗ trợ alias bằng dấu +: email gửi đến [email protected] và [email protected] đều vào inbox của [email protected]. Có thể dùng một inbox thật để nhận email verification cho nhiều test account.
// fixtures/alias-accounts.ts
export const WORKER_ACCOUNTS = [
{ email: '[email protected]', password: 'SharedPass0!' },
{ email: '[email protected]', password: 'SharedPass1!' },
{ email: '[email protected]', password: 'SharedPass2!' },
{ email: '[email protected]', password: 'SharedPass3!' },
] as const;
// Nếu cần đọc inbox (verify email flow):
// Tất cả các account trên đều gửi về '[email protected]'
// Lọc theo subject hoặc recipient để phân biệt
Ưu điểm:
- Chỉ cần 1 inbox thật để nhận email của tất cả worker account.
- Hữu ích khi test flow xác nhận email (registration, password reset).
- Account đã tồn tại — không cần setup.
Nhược điểm và giới hạn:
- Không phải service email nào cũng hỗ trợ
+alias. Một số hệ thống backend từ chối email có ký tự+. - Ứng dụng có thể normalize email (bỏ phần sau
+) — khi đów0vàw1bị xem là cùng account. - Email từ nhiều worker về cùng inbox có thể gây nhầm lẫn khi đọc email trong test nếu không lọc đúng.
Kiểm tra trước khi dùng: đăng ký thử tài khoản với email alias trên ứng dụng đang test. Nếu ứng dụng chấp nhận [email protected] như một email riêng biệt và lưu đúng định dạng đó, pattern này hoạt động. Nếu ứng dụng normalize thành [email protected] thì không dùng được.
Fail-Fast Guard
Khi pool size nhỏ hơn số workers, test sẽ crash với lỗi khó đọc (TypeError: Cannot read properties of undefined). Thêm guard rõ ràng vào fixture để lỗi được phát hiện sớm với thông báo có ích:
account: [
async ({}, use, workerInfo) => {
const idx = workerInfo.parallelIndex;
if (idx >= accounts.length) {
throw new Error(
`No account for parallelIndex ${idx}. ` +
`Pool size: ${accounts.length}. ` +
`Add ${idx - accounts.length + 1} more account(s) to the pool, ` +
`or reduce workers to ${accounts.length}.`
);
}
await use(accounts[idx]);
},
{ scope: 'worker' },
],
Message lỗi nên bao gồm: giá trị parallelIndex hiện tại, kích thước pool hiện tại, và gợi ý cụ thể — thêm bao nhiêu account hoặc giảm workers xuống bao nhiêu. Khi CI fail với lỗi này, người đọc log biết ngay phải sửa gì.
Guard ở module level (cho static pool):
// Chạy khi module được load — trước khi bất kỳ test nào bắt đầu
const configuredWorkers = parseInt(process.env.PLAYWRIGHT_WORKERS ?? '4', 10);
if (accounts.length < configuredWorkers) {
throw new Error(
`[fixtures/accounts] Pool size ${accounts.length} < workers ${configuredWorkers}. ` +
`Update WORKER_ACCOUNTS array.`
);
}
Module-level check có lợi thế: lỗi xuất hiện ngay lúc import, không cần đợi đến khi worker setup fixture — giúp detect sớm hơn trong local dev.
Pitfalls
Pitfall 1: Dùng workerIndex thay parallelIndex cho account pool
// Sai: workerIndex tăng khi retry → out-of-bounds
async ({}, use, workerInfo) => {
const account = accounts[workerInfo.workerIndex]; // SAI
await use(account);
}
// Đúng: parallelIndex giữ nguyên slot qua retry
async ({}, use, workerInfo) => {
const account = accounts[workerInfo.parallelIndex]; // ĐÚNG
await use(account);
}
Pitfall 2: Pool size nhỏ hơn workers — không có guard
// 3 account nhưng workers: 4 → parallelIndex 3 → crash thầm lặng
const accounts = [
{ email: '[email protected]', password: 'p0' },
{ email: '[email protected]', password: 'p1' },
{ email: '[email protected]', password: 'p2' },
// Thiếu w3!
];
// Lỗi gốc: "Cannot read properties of undefined (reading 'email')"
// Khó trace — phải thêm guard rõ ràng
Pitfall 3: Tăng workers mà không cập nhật pool
Hay gặp khi migrate từ CI 2 workers lên 4 workers mà quên thêm account. Giải pháp: đặt pool size check trong CI pipeline hoặc pretest script, không để lọt vào run thực.
Pitfall 4: Email alias không được app hỗ trợ
// App normalize email → cùng account
// '[email protected]' và '[email protected]' → '[email protected]'
// → Hai worker login cùng account → conflict session
Kiểm tra behavior của app trước khi dùng email alias. Nếu app chấp nhận nhưng lưu phiên bản normalized, tất cả worker sẽ cạnh tranh cùng một session.
Pitfall 5: Dynamic account không cleanup khi worker crash
// Nếu worker bị kill trước khi teardown chạy:
account: [
async ({}, use) => {
const acc = await createUserViaAPI(...);
await use(acc);
await deleteUserViaAPI(acc.id); // KHÔNG chạy nếu worker crash
},
{ scope: 'worker' },
]
Với dynamic account, nên thêm cleanup job định kỳ (cron hoặc globalTeardown) để xóa account test cũ theo pattern email (ví dụ test-w%-%[email protected]). Không nên dựa hoàn toàn vào fixture teardown.
Quiz
Câu 1. Cấu hình workers: 4, retries: 2. Account pool có 4 phần tử. Test A chạy trên slot 1 (parallelIndex=1), fail, retry hai lần. Ở lần retry thứ 2, accounts[workerInfo.parallelIndex] trả về phần tử nào?
Đáp án
accounts[1] — phần tử index 1. parallelIndex không thay đổi qua retry (luôn là 1 cho slot 1), dù workerIndex đã tăng lên. Đây là lý do dùng parallelIndex để map vào account pool.
Câu 2. Nếu dùng workerIndex thay vì parallelIndex trong câu 1, điều gì xảy ra ở retry lần 2 (giả sử đây là worker process thứ 6, workerIndex=5)?
Đáp án
accounts[5] là undefined vì pool chỉ có 4 phần tử (index 0–3). Fixture sẽ crash với TypeError hoặc truyền undefined vào test, gây lỗi khó trace. Nếu có fail-fast guard, lỗi sẽ rõ ràng hơn: "No account for parallelIndex 5. Pool size: 4".
Câu 3. CI server có 2 logical CPU. Config dùng workers: '50%'. Account pool cần bao nhiêu phần tử tối thiểu?
Đáp án
2 phần tử. '50%' của 2 CPU = 1 worker — nhưng Playwright tối thiểu là 1 worker, nên thực tế là 1 worker với parallelIndex 0. Tuy nhiên, nếu config này cùng dùng trên máy dev 8 CPU thì '50%' = 4 workers. Để an toàn, pool size nên bằng max workers có thể chạy trong toàn bộ môi trường — không chỉ CI. Dùng workers cố định (ví dụ workers: 2 cho CI) thay vì '50%' khi muốn pool size predictable.
Câu 4. Ứng dụng đang test normalize email: [email protected] → lưu thành [email protected]. Dùng email alias pattern với 4 worker account [email protected]…[email protected] có ổn không? Tại sao?
Đáp án
Không ổn. Khi ứng dụng normalize, tất cả 4 email alias đều map về [email protected]. Đăng ký lần đầu thành công, nhưng từ lần thứ 2 trở đi sẽ báo "email đã tồn tại". Ngay cả khi ứng dụng cho phép đăng nhập với alias, session cuối cùng sẽ là cùng một tài khoản — 4 worker cạnh tranh một session, dẫn đến conflict. Trong trường hợp này nên dùng static pool với email hoàn toàn khác nhau, hoặc dynamic account.
Bài Tiếp Theo
Bài 106: API Login & storageState — thay vì login qua UI, dùng request fixture để gọi API endpoint trực tiếp, lưu cookies/localStorage vào file JSON và nạp vào context. Nhanh hơn UI login và phù hợp với per-worker auth khi cần minimize thời gian setup.
