Danh sách bài viết

Bài 104: Per-Worker Auth Pattern

Khi test chạy song song và nhiều worker dùng chung một account, mutation từ worker này ảnh hưởng dữ liệu mà worker khác đang đọc — dẫn đến race condition không ổn định. Per-worker auth giải quyết vấn đề này bằng cách gán mỗi worker một account riêng biệt từ một pool được chuẩn bị trước, đảm bảo không có hai worker nào tranh nhau state của cùng một account.

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

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

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

  • Giải thích được tại sao shared account gây race condition trong test parallel có mutation.
  • Thiết kế account pool đủ lớn cho số worker tối đa.
  • Khai báo worker-scope fixture workerStorageState login 1 lần per worker.
  • Override built-in fixture storageState để toàn bộ test tự động dùng auth đúng account.
  • Dùng parallelIndex (không phải workerIndex) để map worker vào account slot ổn định.
  • Hiểu khi nào cần xóa worker auth file giữa các run.
  • Tránh 4 pitfall điển hình của pattern này.

Bài này không lặp lại cơ chế storageState cơ bản (bài 102), per-file auth (bài 103), hay worker-scope fixture mechanics (bài 21). Focus vào pattern xử lý account isolation cho mutating test.

2

Vấn Đề Shared Account Khi Test Parallel

Nhiều pattern auth đơn giản login một lần và lưu storageState vào file dùng chung cho mọi test. Điều này hoạt động tốt khi các test chỉ đọc dữ liệu — nhưng thất bại khi test ghi hoặc thay đổi state của account.

Kịch bản race condition điển hình

Giả sử app có tính năng chỉnh sửa profile. Hai test chạy song song trên hai worker, cùng dùng một account [email protected]:

Worker 0 — test A: "đổi tên hiển thị thành Alice"
Worker 1 — test B: "xác nhận tên hiển thị là tên gốc"

Timeline (song song):
  t=0:  Worker 0 GET /profile → đọc name = "Original"
  t=0:  Worker 1 GET /profile → đọc name = "Original"
  t=1:  Worker 0 PUT /profile name = "Alice" → ghi thành công
  t=2:  Worker 1 GET /profile → đọc name = "Alice" ← kỳ vọng "Original" → FAIL

Test B fail không phải vì có bug trong app — mà vì test A đã thay đổi state của account trước khi test B đọc. Đây là account-level state mutation conflict.

Các dạng mutation gây conflict

  • Profile / settings: đổi tên, avatar, ngôn ngữ, timezone.
  • Cart / wishlist: thêm/xóa sản phẩm — test khác đọc giỏ hàng khác trạng thái ban đầu.
  • Notification state: test A đánh dấu "đã đọc", test B expect "chưa đọc".
  • Subscription / tier: test nâng cấp plan, test khác expect plan cũ.
  • Two-factor / session: đăng xuất hoặc revoke session từ một worker làm worker kia mất auth.

Test flaky do shared account khó debug — failure không tái hiện ổn định vì phụ thuộc vào timing của các worker. Chạy lại đôi khi pass, đôi khi fail tùy worker nào chạy xong trước.

3

Giải Pháp: Mỗi Worker 1 Account Riêng

Per-worker auth gán mỗi worker process một account hoàn toàn riêng biệt. Worker 0 luôn dùng account[0], worker 1 dùng account[1], v.v. Vì không có hai worker nào dùng cùng account, mutation từ worker này không bao giờ ảnh hưởng đến worker kia.

Workers = 4 → cần 4 account:

  Worker 0 (parallelIndex=0) ←→ [email protected]
  Worker 1 (parallelIndex=1) ←→ [email protected]
  Worker 2 (parallelIndex=2) ←→ [email protected]
  Worker 3 (parallelIndex=3) ←→ [email protected]

Mỗi account có state riêng — không giao thoa.

Cơ chế hoạt động:

  • Login được thực hiện 1 lần per worker (worker-scope fixture).
  • Auth state được cache vào file playwright/.auth/worker-{index}.json.
  • Mọi test trong worker đó tự động dùng file auth tương ứng qua override storageState.
4

Account Pool

Account pool là mảng các tài khoản test được tạo trước, đánh index từ 0. Kích thước pool phải bằng hoặc lớn hơn số worker tối đa (workers trong config).

// fixtures/accounts.ts
export const accounts = [
  { email: '[email protected]', password: 'p0' },
  { email: '[email protected]', password: 'p1' },
  { email: '[email protected]', password: 'p2' },
  { email: '[email protected]', password: 'p3' },
];
// workers ≤ accounts.length — bắt buộc

Các account trong pool cần được tạo sẵn trên môi trường test — thường qua script seed hoặc API admin trước khi chạy test suite. Một số điểm cần đảm bảo khi tạo account pool:

  • Mỗi account có state ban đầu nhất quán (cùng role, cùng cấu hình mặc định) để test không phụ thuộc vào trạng thái sót từ run trước.
  • Account được cô lập về dữ liệu — cart, profile, đơn hàng, notification của account 0 không liên quan đến account 1.
  • Nếu app giới hạn session đồng thời, đảm bảo mỗi account chỉ được dùng bởi 1 worker trong cùng một lúc — điều này tự nhiên được đảm bảo bởi pattern per-worker.

Với môi trường CI, thường lưu credentials vào biến môi trường hoặc secret store thay vì hardcode trong file:

// Đọc từ env nếu có, fallback về default cho local dev
export const accounts = Array.from({ length: 4 }, (_, i) => ({
  email: process.env[`WORKER_EMAIL_${i}`] ?? `test-worker-${i}@x.com`,
  password: process.env[`WORKER_PASSWORD_${i}`] ?? `p${i}`,
}));
5

Worker-Scope Auth Fixture

Fixture workerStorageState khai báo với scope: 'worker' — chạy 1 lần per worker, trả về đường dẫn file auth của worker đó:

import { test as base } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { accounts } from './accounts';

export const test = base.extend<{}, { workerStorageState: string }>({
  workerStorageState: [
    async ({ browser }, use) => {
      const id = test.info().parallelIndex;
      const fileName = path.resolve(`playwright/.auth/worker-${id}.json`);

      if (fs.existsSync(fileName)) {
        await use(fileName);  // reuse nếu đã login
        return;
      }

      // Login với account riêng cho worker này
      const page = await browser.newPage();
      const account = accounts[id];  // pre-created account pool
      await page.goto('/login');
      await page.getByLabel('Email').fill(account.email);
      await page.getByLabel('Password').fill(account.password);
      await page.getByRole('button', { name: 'Login' }).click();
      await page.context().storageState({ path: fileName });
      await page.close();

      await use(fileName);
    },
    { scope: 'worker' },
  ],

  storageState: ({ workerStorageState }, use) => use(workerStorageState),
});

Một số điểm đáng chú ý trong implementation này:

  • test.info().parallelIndex — đọc parallelIndex của test hiện tại trong worker-scope fixture. Đây là cách truy cập worker index khi đang ở trong worker-scope context (không có testInfo truyền vào).
  • browser — là built-in fixture của Playwright, khả dụng trong worker scope vì browser cũng có scope worker.
  • Fixture tạo trang riêng (browser.newPage()), thực hiện login, lưu state, rồi đóng trang — không ảnh hưởng đến context của test.
  • File playwright/.auth/ nên có trong .gitignore.

Đảm bảo thư mục tồn tại

Nếu thư mục playwright/.auth/ chưa tồn tại, storageState({ path: fileName }) sẽ throw. Thêm kiểm tra trước khi lưu:

import fs from 'fs';
import path from 'path';

// Trước khi gọi storageState
const dir = path.dirname(fileName);
if (!fs.existsSync(dir)) {
  fs.mkdirSync(dir, { recursive: true });
}
await page.context().storageState({ path: fileName });
6

Override Built-in storageState

Playwright cho phép override fixture built-in bằng cách khai báo lại trong base.extend(). Dòng cuối của fixture definition:

storageState: ({ workerStorageState }, use) => use(workerStorageState),

override fixture storageState built-in. Khi Playwright tạo BrowserContext cho mỗi test, nó đọc storageState để load cookie/localStorage — giờ nó nhận được path file auth của worker đó.

Nhờ override này, test không cần biết về per-worker auth. Code test viết bình thường:

// test-file.ts — import test từ fixtures, không phải từ @playwright/test
import { test, expect } from './fixtures';

test('chỉnh sửa profile', async ({ page }) => {
  // Context đã được load với auth của worker này
  await page.goto('/profile');
  await page.getByLabel('Tên hiển thị').fill('Alice');
  await page.getByRole('button', { name: 'Lưu' }).click();
  await expect(page.getByText('Cập nhật thành công')).toBeVisible();
});

Luồng hoàn chỉnh khi test chạy:

Worker process start (parallelIndex=2)
│
├── [Worker] workerStorageState setup
│     id = 2
│     file = playwright/.auth/worker-2.json
│     file không tồn tại → login với accounts[2]
│     → lưu state vào worker-2.json
│     → use(fileName)
│
├── storageState = "playwright/.auth/worker-2.json"
│
├── [Test 1] BrowserContext tạo với storageState=worker-2.json
│     → page đã authenticated với account 2
│     test chạy, có thể mutate profile account 2
│
├── [Test 2] BrowserContext mới với cùng storageState
│     → page authenticated với account 2
│     → state account 2 (có thể đã bị test 1 mutate)
│
└── [Worker] workerStorageState teardown (use() return)

Lưu ý: các test trong cùng worker dùng cùng auth file nhưng BrowserContext riêng biệt. Mutation test A thực hiện trên server (profile, cart...) sẽ được test B thấy khi load page — nhưng đây là mutation trong cùng account của worker đó, không còn conflict giữa các worker nữa.

7

Auth File Caching

Logic trong fixture kiểm tra fs.existsSync(fileName) trước khi login:

if (fs.existsSync(fileName)) {
  await use(fileName);  // reuse nếu đã login
  return;
}
// Nếu không → thực hiện login mới

Caching này có tác dụng:

  • Trong cùng run: nếu worker bị restart (do retry), file đã có → bỏ qua login. Tiết kiệm thời gian, giảm tải lên auth server.
  • Giữa các run liên tiếp: nếu file còn hợp lệ (session chưa expire), run sau không cần login lại — hữu ích trên local dev khi chạy test nhiều lần trong ngày.

Rủi ro của cache

File cache có thể trở nên stale khi:

  • Session expire trên server (JWT hết hạn, cookie timeout).
  • Account bị đổi password hoặc bị vô hiệu hóa.
  • App thay đổi cơ chế auth (đổi key, đổi domain cookie).
  • File bị corrupt hoặc thiếu fields mà app mới yêu cầu.

Khi cache stale, test sẽ fail ở bước cần auth — thường với error redirect về trang login hoặc 401. Fix là xóa file cache và chạy lại để login mới.

8

parallelIndex vs workerIndex Cho Account Pool

Đây là điểm kỹ thuật quan trọng nhất của pattern. Per-worker auth bắt buộc dùng parallelIndex, không phải workerIndex.

Tại sao parallelIndex?

parallelIndexslot-based: luôn nằm trong khoảng [0, workers-1]. Khi một worker kết thúc và worker mới được spawn (do retry hoặc recycle), worker mới nhận lại slot cũ — giá trị parallelIndex không đổi.

workerIndexmonotonic counter: tăng dần, không bao giờ reset. Nếu có retry, worker mới nhận workerIndex cao hơn — có thể vượt quá kích thước account pool.

Config: workers=4, retries=2, accounts.length=4

Scenario: test fail và retry 2 lần trên slot 0

Lần 1: Worker process #0 (workerIndex=0, parallelIndex=0)
  → accounts[parallelIndex=0] ✓
  → accounts[workerIndex=0]   ✓  (trùng lần đầu)

Retry 1: Worker process #4 (workerIndex=4, parallelIndex=0)
  → accounts[parallelIndex=0] ✓  (cùng account, ổn định)
  → accounts[workerIndex=4]   ✗  (undefined — out of bounds!)

Retry 2: Worker process #5 (workerIndex=5, parallelIndex=0)
  → accounts[parallelIndex=0] ✓
  → accounts[workerIndex=5]   ✗  (undefined)

Với parallelIndex, worker retry vẫn dùng cùng account với lần chạy đầu — đúng hành vi mong muốn, và không bao giờ out-of-bounds nếu accounts.length >= workers.

Deep dive parallelIndex

Sự khác biệt chi tiết giữa hai index — lifecycle, hành vi khi sharding, edge case — được cover tại bài 105 chuyên về parallelIndex.

9

Per-Worker vs Per-Role Auth

Hai pattern này giải quyết bài toán khác nhau và có thể kết hợp:

Đặc điểm Per-Role Auth (bài 101) Per-Worker Auth (bài này)
Isolation theo Role (admin, user, moderator) Worker process (slot)
Số account cần = số role = số worker tối đa
Mục đích chính Test permission, UI theo role Tránh conflict khi test mutate data
Phù hợp khi Test read-only hoặc isolated action Test có side effect lên account state
Auth file admin.json, user.json worker-0.json, worker-1.json

Kết hợp per-worker × per-role

Khi suite cần test nhiều role VÀ có mutation, kết hợp cả hai: mỗi worker có một account riêng cho từng role.

// Account pool theo worker × role
export const accounts = {
  admin: [
    { email: '[email protected]', password: 'pa0' },
    { email: '[email protected]', password: 'pa1' },
    { email: '[email protected]', password: 'pa2' },
    { email: '[email protected]', password: 'pa3' },
  ],
  user: [
    { email: '[email protected]', password: 'pu0' },
    { email: '[email protected]', password: 'pu1' },
    { email: '[email protected]', password: 'pu2' },
    { email: '[email protected]', password: 'pu3' },
  ],
};

// Worker i dùng accounts.admin[i] cho admin test
// và accounts.user[i] cho user test

Pattern này cần workers × roles account — phù hợp cho suite trung bình. Với suite lớn hơn, xem xét DB isolation (bài 21) thay vì account isolation.

10

Use Cases Phù Hợp

1. Mutating tests — profile/settings

Test cập nhật thông tin cá nhân, cài đặt giao diện, ngôn ngữ, timezone. Mỗi worker có account riêng → mutation của worker này không làm worker kia thấy profile sai.

2. Stateful e-commerce flows

Cart, checkout, order history, wishlist — các test thêm/xóa sản phẩm, đặt hàng, kiểm tra trạng thái đơn hàng. Worker riêng account riêng → không có cart cross-contamination.

3. Subscription / tier management

Test nâng cấp plan, kiểm tra feature sau upgrade, downgrade, hủy subscription. State subscription của mỗi account không bị ảnh hưởng bởi test worker khác.

4. Notification / inbox state

Test đánh dấu thông báo đã đọc, xóa thông báo, kiểm tra badge count. Mỗi account có inbox riêng → count không sai do worker khác đọc/xóa.

5. Parallel safety cho suite lớn

Khi tăng worker count để chạy nhanh hơn, per-worker auth đảm bảo không cần giảm worker hoặc serialise test vì lo conflict account.

11

Cleanup Worker Auth Files

Worker auth file tồn tại giữa các run (do caching). Điều này có lợi cho tốc độ nhưng có thể gây vấn đề khi cần fresh auth.

Khi nào cần xóa worker auth files

  • Session timeout ngắn (dưới 1 tiếng) — file từ run trước đã expire.
  • Deploy mới của app với breaking change cho cookie/token.
  • Password của account trong pool bị đổi.
  • Debug lỗi nghi do stale auth.

Xóa thủ công

rm -f playwright/.auth/worker-*.json

Xóa tự động trước run (global setup)

// global-setup.ts
import fs from 'fs';
import path from 'path';
import { glob } from 'glob';

export default async function globalSetup() {
  // Xóa worker auth files để đảm bảo fresh login mỗi run
  const files = await glob('playwright/.auth/worker-*.json');
  for (const file of files) {
    fs.unlinkSync(file);
  }
}

Cân nhắc trade-off: xóa mỗi run đảm bảo auth luôn fresh nhưng mỗi worker phải login lại → tốn thêm vài giây nếu có nhiều worker. Nếu session dài (ví dụ 8 tiếng) và run nhiều lần trong ngày, có thể giữ cache và chỉ xóa theo cron hoặc khi cần.

12

Common Pitfalls

1. Workers > account pool size — out-of-bounds

// Config: workers: 6
// Pool: chỉ 4 accounts
const accounts = [
  { email: '[email protected]', password: 'p0' },
  { email: '[email protected]', password: 'p1' },
  { email: '[email protected]', password: 'p2' },
  { email: '[email protected]', password: 'p3' },
  // Thiếu w4 và w5!
];

// parallelIndex có thể là 4 hoặc 5
// accounts[4] = undefined → TypeError khi fill email

Fix: đảm bảo accounts.length >= workers. Thêm guard để fail sớm với thông báo rõ ràng:

const id = test.info().parallelIndex;
if (id >= accounts.length) {
  throw new Error(
    `Account pool (${accounts.length}) nhỏ hơn workers. ` +
    `parallelIndex=${id}. Thêm account hoặc giảm workers.`
  );
}
const account = accounts[id];

2. Dùng workerIndex thay parallelIndex — worker recycle dùng account lạ

// SAI — workerIndex tăng dần, vượt pool khi retry
const id = test.info().workerIndex; // ← sai field
const fileName = `playwright/.auth/worker-${id}.json`;

// Khi test fail và retry sinh worker mới (workerIndex=4):
// accounts[4] = undefined nếu pool chỉ có 4 account
// Hoặc dùng account khác với lần đầu → inconsistent test state

Dùng test.info().parallelIndex — giữ nguyên slot qua retry.

3. Worker auth file stale — test redirect về login

Triệu chứng: test fail ở step đầu tiên với redirect về /login hoặc response 401, dù không có code thay đổi. Chạy lại tiếp tục fail.

Fix: xóa file cache và chạy lại để login mới:

rm -f playwright/.auth/worker-*.json && npx playwright test

4. Shared account không per-worker — race condition mutating test

Pattern anti-example: nhiều worker dùng chung 1 file auth:

// playwright.config.ts — SAI cho mutating test
export default defineConfig({
  use: {
    storageState: 'playwright/.auth/user.json',  // ← dùng chung
  },
});

Nếu test chỉ đọc (không thay đổi state), shared auth file hoàn toàn ổn và hiệu quả hơn. Per-worker auth chỉ cần thiết khi có test mutate state của account.

13

Tổng Kết

  • Per-worker auth giải quyết race condition khi test parallel cùng mutate state của 1 account.
  • Account pool phải có kích thước ≥ workers; worker i (theo parallelIndex) dùng accounts[i].
  • Fixture workerStorageState với scope: 'worker' thực hiện login 1 lần per worker và cache kết quả vào file.
  • Override storageState fixture để toàn bộ test tự động nhận auth đúng — không cần thay đổi code test.
  • Dùng parallelIndex — giữ nguyên slot qua retry, không bao giờ out-of-bounds nếu pool đủ lớn.
  • Auth file cache giúp tiết kiệm login overhead nhưng có thể stale; xóa playwright/.auth/worker-*.json khi cần fresh auth.
  • Per-worker auth chỉ cần thiết cho mutating tests — nếu test chỉ đọc, shared auth file đơn giản hơn và đủ tốt.
14

Bài Tập Củng Cố

Câu 1

Config workers: 4, retries: 1. Account pool có 4 account. Test X chạy trên slot 2 (parallelIndex=2), fail, bị retry. Worker mới được spawn với workerIndex=5. Câu hỏi: (a) parallelIndex của worker mới là bao nhiêu? (b) account nào được dùng? (c) auth file nào được load?

Đáp án

(a) parallelIndex=2 — slot không thay đổi khi retry, worker mới kế thừa slot của worker cũ.

(b) accounts[2][email protected].

(c) playwright/.auth/worker-2.json — file đã tồn tại từ lần chạy đầu → fixture reuse, không login lại.

Câu 2

Fixture workerStorageState dùng scope: 'worker'. Suite có 3 worker, mỗi worker chạy 8 test. Tổng số lần login xảy ra (giả sử không có auth file cache) là bao nhiêu?

Đáp án

3 lần — 1 lần per worker. Worker-scope fixture chỉ setup 1 lần khi test đầu tiên trong worker request fixture đó. 8 test còn lại trong cùng worker reuse kết quả, không login lại.

Câu 3

Đoạn code sau có vấn đề gì? Fix như thế nào?

export const test = base.extend<{}, { workerStorageState: string }>({
  workerStorageState: [
    async ({ browser }, use) => {
      const id = test.info().workerIndex;  // ← dòng này
      const fileName = path.resolve(`playwright/.auth/worker-${id}.json`);
      // ...
    },
    { scope: 'worker' },
  ],
  storageState: ({ workerStorageState }, use) => use(workerStorageState),
});
Đáp án

Dùng workerIndex thay vì parallelIndex. Khi test fail và retry sinh worker mới (workerIndex tăng), fixture sẽ dùng file worker-{N}.json với N lớn — không match với file đã login từ lần đầu (nếu có cache), và có thể trỏ vào account ngoài pool.

Fix: đổi sang test.info().parallelIndex — slot ổn định qua retry.

Câu 4

Test suite chạy với workers: 4. Sau một tuần, team thêm tính năng mới và thay đổi cơ chế JWT (đổi secret key). Test bắt đầu fail ngẫu nhiên ở step đầu tiên. Nguyên nhân và cách fix?

Đáp án

Auth file cache trong playwright/.auth/worker-*.json chứa JWT cũ không còn hợp lệ với secret key mới. Khi context load storageState, cookie/token cũ bị server reject → redirect về login.

Fix: xóa toàn bộ file cache rồi chạy lại để login mới:

rm -f playwright/.auth/worker-*.json && npx playwright test

Phòng ngừa: thêm bước xóa worker auth file vào global setup hoặc CI pipeline khi có deploy mới.

Câu 5

Khi nào không nên dùng per-worker auth và thay bằng shared auth file? Cho 2 ví dụ cụ thể.

Đáp án

Không cần per-worker auth khi test chỉ đọc và không thay đổi state của account:

  1. Test kiểm tra hiển thị UI theo role: kiểm tra admin thấy menu "Quản lý user", user thường không thấy. Không có mutation → shared auth file cho admin và user là đủ, không cần N account × 4 worker.
  2. Test đọc catalog sản phẩm hoặc nội dung public: test danh sách sản phẩm, tìm kiếm, lọc — chỉ GET requests, không thay đổi gì trên account → mọi worker dùng chung 1 file auth hoàn toàn ổn.

Shared auth đơn giản hơn (ít account, ít file, không cần override fixture) và đủ tốt cho test read-only.

15

Bài Tiếp Theo

Bài 105 đào sâu vào parallelIndex — tại sao nó ổn định qua retry, hành vi khi sharding, và các pattern dùng index này ngoài account pool.

Bài 105: parallelIndex — Slot-Based Worker Identity