Mục lục
Mục Tiêu Bài Học
Sau khi hoàn thành bài này, bạn sẽ:
- Giải thích được ranh giới state giữa in-worker và cross-worker.
- Dùng đúng 3 cơ chế share state trong worker: module-level variable, worker fixture, beforeAll/afterAll binding.
- Biết khi nào module-level variable đủ dùng, khi nào cần worker fixture.
- Áp dụng lazy init pattern để tránh init nặng nhiều lần per worker.
- Hiểu tại sao module-level variable không share được cross-worker và cách thay thế.
- Nhận biết 4 pitfall điển hình: cross-worker assumption, test order dependency, top-level async issue, worker recycle.
Bài này không lặp lại cú pháp khai báo worker fixture (bài 21) hay globalSetup (A.11) — focus vào pattern và pitfall của state sharing.
Worker Process Và Ranh Giới State
Playwright Test chạy test trong các worker process riêng biệt — mỗi worker là 1 Node.js process độc lập. Khi bạn đặt workers: 4, 4 process này chạy đồng thời và không chia sẻ bộ nhớ với nhau.
Bên trong mỗi worker, các test chạy tuần tự (1 test 1 lúc, kể cả khi fullyParallel: true — parallel ở đây là giữa worker, không phải trong worker). Đây là nền tảng để hiểu phạm vi state sharing:
Playwright run — workers: 3
Process A (worker 0) Process B (worker 1) Process C (worker 2)
├── module cache A ├── module cache B ├── module cache C
├── test 1 → test 2 → test 3 ├── test 4 → test 5 → test 6 ├── test 7 → test 8 → test 9
└── [share state OK] └── [share state OK] └── [share state OK]
Module cache A ≠ B ≠ C → module-level variable KHÔNG share cross-worker
Có 2 loại "share":
- In-worker: test 1, 2, 3 trong cùng process A có thể thấy cùng state. Đây là phạm vi của module-level variable và worker fixture.
- Cross-worker: process A, B, C muốn dùng chung data. Không có shared memory — phải qua file system, database, hoặc IPC. Module-level variable không đáp ứng được.
Cơ Chế 1: Module-Level Variable
Node.js cache module sau lần require / import đầu tiên. Mọi file cùng import 1 module sẽ nhận cùng 1 object instance — miễn là trong cùng process. Điều này áp dụng cho module-level variable.
// shared-state.ts
let counter = 0;
const requestLog: string[] = [];
export function increment(): number {
return ++counter;
}
export function logRequest(url: string): void {
requestLog.push(url);
}
export function getStats() {
return { counter, requestCount: requestLog.length };
}
Khi nhiều file test trong cùng worker cùng import shared-state.ts:
// tests/a.spec.ts
import { increment, logRequest } from './shared-state';
test('test A1', async ({ page }) => {
logRequest('/api/users');
console.log(increment()); // 1
});
test('test A2', async ({ page }) => {
console.log(increment()); // 2 — cùng module instance với test A1
});
// tests/b.spec.ts (cùng worker với a.spec.ts)
import { increment, getStats } from './shared-state';
test('test B1', async ({ page }) => {
console.log(increment()); // 3 — cùng module instance với a.spec.ts
console.log(getStats()); // { counter: 3, requestCount: 1 }
});
Điều kiện để share:
- Cùng worker process (cùng Node.js process).
- Import cùng đường dẫn module (Node module cache dùng path làm key).
- Module dùng CommonJS hoặc ESM với bundler — ESM native (node16/nodenext) có module cache riêng nhưng hành vi tương tự trong cùng process.
Module-level variable phù hợp khi cần counter đơn giản, log buffer, hay flag init. Không phù hợp khi cần cleanup resource (vì không có teardown hook).
Cơ Chế 2: Worker-Scope Fixture
Worker fixture (bài 21) là cách share state có lifecycle rõ ràng: setup khi worker bắt đầu, teardown sau test cuối của worker. Nó tạo 1 instance per worker và inject vào mọi test trong worker đó.
// fixtures/worker-fixtures.ts
import { test as base } from '@playwright/test';
type WorkerData = { count: number; log: string[] };
export const test = base.extend<{}, { sharedData: WorkerData }>({
sharedData: [
async ({}, use) => {
// Tạo 1 lần per worker
const data: WorkerData = { count: 0, log: [] };
await use(data);
// Teardown: data sẽ bị GC khi worker exit
},
{ scope: 'worker' },
],
});
Dùng trong test:
// tests/counter.spec.ts
import { test } from '../fixtures/worker-fixtures';
import { expect } from '@playwright/test';
test('test 1', async ({ sharedData }) => {
sharedData.count++;
sharedData.log.push('test-1');
expect(sharedData.count).toBe(1);
});
test('test 2', async ({ sharedData }) => {
// Nếu test 1 chạy trước trong cùng worker:
sharedData.count++;
expect(sharedData.count).toBe(2); // share state từ test 1
console.log(sharedData.log); // ['test-1']
});
Worker fixture không phải là module-level variable. Sự khác biệt quan trọng:
- Worker fixture có teardown — dọn dẹp connection, file, resource khi worker xong.
- Worker fixture inject qua destructuring — type-safe, không cần import module.
- Module-level variable không có teardown — phù hợp cho pure data, không phải resource.
Cơ Chế 3: beforeAll / afterAll Với Module Binding
test.beforeAll chạy 1 lần trước tất cả test trong describe block (hoặc file). Kết hợp với module-level variable, bạn có thể init resource 1 lần và share qua closure:
// tests/db.spec.ts
import { test, expect } from '@playwright/test';
import { createPool, Pool } from 'pg';
// Module-level binding — share trong file này
let pool: Pool;
test.beforeAll(async () => {
pool = createPool({ connectionString: process.env.DATABASE_URL });
// Verify connection
await pool.query('SELECT 1');
});
test.afterAll(async () => {
await pool.end();
});
test('query users', async () => {
const result = await pool.query('SELECT COUNT(*) FROM users');
expect(Number(result.rows[0].count)).toBeGreaterThan(0);
});
test('query orders', async () => {
// Cùng pool instance — không tạo lại
const result = await pool.query('SELECT COUNT(*) FROM orders');
expect(Number(result.rows[0].count)).toBeGreaterThan(0);
});
Giới hạn của cơ chế này:
- State chỉ share trong phạm vi 1 file / 1 describe block. File khác trong cùng worker không nhìn thấy
poolnày. - Nếu 2 file cùng dùng beforeAll để tạo pool riêng → mỗi file tạo 1 pool, không share.
- So sánh: worker fixture share qua nhiều file trong cùng worker — beforeAll chỉ share trong 1 file.
Cơ chế này hữu ích khi resource chỉ cần trong 1 file và không muốn tạo fixture riêng.
So Sánh 3 Cơ Chế
| Đặc điểm | Module-level variable | Worker fixture | beforeAll / module binding |
|---|---|---|---|
| Phạm vi share | Mọi file trong cùng worker (qua import) | Mọi file trong cùng worker (qua inject) | 1 file / 1 describe block |
| Lifecycle / teardown | Không có — biến sống đến process exit | Có — sau test cuối của worker | Có — afterAll trong cùng scope |
| Type safety | Qua TypeScript import | Có — qua generic type WorkerFixtures | Qua closure |
| Init timing | Khi module load (import) | Khi test đầu tiên request fixture | Khi beforeAll chạy |
| Phù hợp cho | Counter, flag, pure data, cache nhỏ | DB pool, mock server, Redis client, compiled asset | Setup one-off trong 1 file |
| Reuse qua nhiều file | Có (cùng worker) | Có (cùng worker) | Không |
Lazy Init Pattern
Khi dùng module-level variable để cache resource nặng (parse config lớn, load fixture JSON, init heavy client), vấn đề xảy ra nếu init code chạy top-level (tại thời điểm import). Cách an toàn hơn là dùng lazy init: chỉ init khi lần đầu cần.
// helpers/heavy-config.ts
// KHÔNG nên — chạy ngay khi module được import, blocking
// const config = JSON.parse(fs.readFileSync('big-config.json', 'utf-8'));
// Nên — lazy init
let cached: BigConfig | null = null;
export async function getConfig(): Promise<BigConfig> {
if (!cached) {
const raw = await fs.promises.readFile('big-config.json', 'utf-8');
cached = JSON.parse(raw) as BigConfig;
}
return cached;
}
Trong test:
// tests/feature.spec.ts
import { getConfig } from '../helpers/heavy-config';
test('test A', async () => {
const config = await getConfig(); // init lần đầu — đọc file
// ...
});
test('test B', async () => {
const config = await getConfig(); // lần 2 — return cached, không đọc file lại
// ...
});
Điểm cần lưu ý về lazy init với module-level cache:
- Chỉ init 1 lần per worker — vì mỗi worker có module cache riêng.
- Không cần lock — test trong worker chạy tuần tự, không có concurrent call vào
getConfig()từ 2 test cùng lúc. - Init sẽ lặp lại khi worker recycle (sau retry — xem phần pitfall). Đây không phải lỗi — mỗi process cần init riêng.
So sánh với top-level await trong ESM:
// TRÁNH top-level await trong module dùng làm shared state
// top-level await chạy async nhưng module khác import cùng lúc có thể thấy state chưa ready
// helpers/risky.ts (ESM)
export const config = await loadConfig(); // top-level await — race nếu module load song song
// An toàn hơn: dùng lazy init function như trên
Use Cases Thực Tế
1. DB connection pool (worker fixture)
Tạo pool 1 lần per worker, reuse cho mọi test — không đóng/mở connection mỗi test.
// fixtures/db-fixtures.ts
import { test as base } from '@playwright/test';
import { Pool } from 'pg';
export const test = base.extend<{}, { dbPool: Pool }>({
dbPool: [
async ({}, use, workerInfo) => {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
application_name: `pw_worker_${workerInfo.workerIndex}`,
});
await use(pool);
await pool.end();
},
{ scope: 'worker' },
],
});
2. Cache pre-computed fixture data (module-level + lazy init)
Load file JSON seed 1 lần per worker — không cần worker fixture vì không có resource để teardown.
// fixtures/seed-cache.ts
import { readFile } from 'fs/promises';
type SeedData = { users: User[]; products: Product[] };
let cached: SeedData | null = null;
export async function getSeedData(): Promise<SeedData> {
if (!cached) {
const raw = await readFile('test/fixtures/seed.json', 'utf-8');
cached = JSON.parse(raw) as SeedData;
}
return cached;
}
Trong test — không cần fixture, chỉ cần gọi function:
import { getSeedData } from '../fixtures/seed-cache';
test('product page shows correct items', async ({ page }) => {
const { products } = await getSeedData();
await page.goto('/products');
await expect(page.getByText(products[0].name)).toBeVisible();
});
3. Counter / metrics per worker (module-level)
Track số request, số lần retry, timing per worker — hữu ích khi debug performance.
// helpers/worker-metrics.ts
const metrics = {
apiCallCount: 0,
totalDuration: 0,
};
export function recordApiCall(durationMs: number): void {
metrics.apiCallCount++;
metrics.totalDuration += durationMs;
}
export function getMetrics() {
return { ...metrics };
}
4. Parse nặng 1 lần (module-level + lazy init)
// helpers/schema-cache.ts
import Ajv, { AnySchema } from 'ajv';
let ajv: Ajv | null = null;
export function getValidator(): Ajv {
if (!ajv) {
// Compile schema — tốn CPU, chỉ làm 1 lần per worker
ajv = new Ajv({ strict: true });
ajv.addSchema(/* schemas... */);
}
return ajv;
}
Cross-Worker Sharing
Module-level variable và worker fixture chỉ share trong 1 worker. Khi cần nhiều worker đọc cùng data, phải dùng persistent storage bên ngoài process.
Các phương án cross-worker:
| Phương án | Cách dùng | Concurrency |
|---|---|---|
| File system (JSON / text) | Worker ghi, các worker khác đọc | Race condition nếu nhiều worker cùng ghi |
| Database | Seed data trước, workers read-only | OK nếu read-only, cần transaction nếu ghi |
| Redis / Memcached | Cache shared, atomic operations | Tốt — atomic SETNX, TTL |
| globalSetup (A.11) | Chạy 1 lần trước tất cả worker | Không có concurrency — 1 process |
Pattern file-based với lock đơn giản (ví dụ dùng cho một-lần seed):
// CẢNH BÁO: pattern này có race condition khi nhiều worker start cùng lúc.
// Chỉ dùng khi không có option nào khác và chấp nhận retry.
// Cách đúng: dùng globalSetup.
import { test } from '@playwright/test';
import * as fs from 'fs';
test.beforeAll(async ({}, testInfo) => {
const lockFile = '.test-seed-lock';
// Chỉ worker đầu tiên tạo được lock file (không atomic trên FS thông thường)
if (!fs.existsSync(lockFile)) {
try {
fs.writeFileSync(lockFile, testInfo.workerIndex.toString());
// Seed database — chỉ 1 lần
await seedDatabase();
} catch {
// Worker khác đã tạo file trước
}
}
// Đợi seed xong
while (!fs.existsSync('.test-seed-done')) {
await new Promise(r => setTimeout(r, 100));
}
});
Pattern này dễ có race condition vì fs.existsSync + fs.writeFileSync không phải atomic. Cách đáng tin cậy hơn là chạy seed trong globalSetup — 1 process, không có concurrency.
Pitfalls
Pitfall 1 — Expect cross-worker nhưng chỉ có in-worker
Lỗi phổ biến nhất: viết module-level variable để share data và expect tất cả worker cùng thấy — nhưng thực tế mỗi worker có module cache riêng.
// helpers/global-counter.ts
export let totalTestsRun = 0;
export function markRun() { totalTestsRun++; }
// tests/a.spec.ts — worker 0
import { totalTestsRun, markRun } from '../helpers/global-counter';
test('test A', async () => {
markRun();
console.log(totalTestsRun); // 1 — chỉ đếm trong worker 0
});
// tests/b.spec.ts — worker 1
import { totalTestsRun, markRun } from '../helpers/global-counter';
test('test B', async () => {
markRun();
console.log(totalTestsRun); // 1 — worker 1 có module instance riêng
// totalTestsRun KHÔNG bao gồm count từ worker 0
});
Fix: nếu cần aggregate cross-worker, ghi vào file sau khi test xong và đọc trong globalTeardown.
Pitfall 2 — Mutation gây test order dependency
Module-level state bị mutate bởi test → test sau nhận state đã thay đổi → kết quả phụ thuộc thứ tự chạy.
// helpers/user-list.ts
export const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
// tests/users.spec.ts
import { users } from '../helpers/user-list';
test('admin can delete user', async () => {
users.splice(0, 1); // Xoá Alice — mutate module-level array
expect(users).toHaveLength(1);
});
test('show all users', async () => {
// Chạy sau test trên → users chỉ còn 1 phần tử thay vì 2
expect(users).toHaveLength(2); // FAIL nếu test trước chạy trong cùng worker
});
Fix: export function trả về clone, hoặc dùng worker fixture kèm test fixture clone per-test.
// helpers/user-list.ts
const _users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
// Luôn trả về clone — test không mutate được original
export function getUsers() {
return [..._users];
}
Pitfall 3 — Top-level async trong ESM
ESM hỗ trợ await ở top-level module. Nếu 2 module cùng import 1 module có top-level await, module đó có thể chưa resolve khi code import chạy — tùy vào thứ tự resolve của bundler / Node loader.
// helpers/config.ts (ESM, tsconfig moduleResolution: node16)
// Rủi ro: nếu module này bị import song song bởi nhiều module trong cùng worker,
// giá trị có thể là undefined trong một khoảnh khắc
export const config = await loadConfig(); // top-level await
// An toàn hơn: export async function
export async function getConfig() {
return loadConfig(); // caller chịu trách nhiệm await
}
Pitfall 4 — Worker recycle reset state
Khi test fail và được retry, Playwright spawn worker mới (worker cũ bị terminate). Module-level state và worker fixture của worker cũ bị hủy — worker mới init lại từ đầu.
// Module-level counter RESET sau worker recycle
let initCount = 0;
export function trackInit() {
initCount++;
console.log(`Init lần ${initCount} trong worker này`);
}
// Nếu worker 0 fail ở test thứ 3 và được spawn lại → worker mới (worker 1)
// initCount trong worker mới = 0, không phải tiếp tục từ worker 0
Đây không phải lỗi của Playwright — đây là behavior đúng. Nhưng nếu code assume state tồn tại qua retry, sẽ có bug. Ví dụ: flag "đã seed DB" trong module-level variable bị reset → worker mới cố seed lại → conflict với data đã seed từ worker cũ.
Fix: dùng persistent storage (file, DB) để track "đã seed chưa" thay vì module-level flag.
Tổng Kết
- Playwright worker = 1 Node.js process riêng. Module-level variable và worker fixture chỉ share in-worker — không cross-worker.
- 3 cơ chế in-worker: module-level variable (đơn giản, không teardown), worker fixture (lifecycle rõ, teardown), beforeAll/module binding (1 file/describe).
- Module-level variable phù hợp cho counter, flag, cache read-only. Worker fixture phù hợp cho resource cần teardown (DB pool, server).
- Lazy init (
if (!cached) { cached = await init() }) an toàn vì test trong worker chạy tuần tự — không có concurrent call. - Cross-worker sharing cần persistent storage: file, DB, Redis. Pattern lock file trên FS có race condition — ưu tiên
globalSetup. - 4 pitfall chính: expect cross-worker (module instance riêng per worker), mutation gây test order dependency, top-level async ESM, worker recycle reset state sau retry.
Quiz
Câu 1
Playwright chạy với workers: 3. File shared.ts export 1 biến let count = 0. Tất cả 3 worker đều import file này và gọi count++ trong mỗi test. Sau khi tất cả test chạy xong, giá trị của count trong worker 0 là bao nhiêu? Trong worker 1?
Đáp án
Mỗi worker có module cache riêng. count trong worker 0 chỉ reflect các lần increment từ test trong worker 0. Tương tự cho worker 1 và worker 2. Giá trị không cross-worker — 3 process, 3 bản copy của count độc lập.
Câu 2
Đoạn code sau có vấn đề gì?
// helpers/cache.ts
export const data = await fetch('https://api.example.com/config').then(r => r.json());
// tests/feature.spec.ts
import { data } from '../helpers/cache';
test('uses config', async () => {
expect(data.version).toBeDefined();
});
Đáp án
Top-level await trong module — rủi ro với ESM khi nhiều module import cache.ts song song trong quá trình resolve. Nếu fetch fail, module load fail và mọi importer đều bị lỗi. Không có retry, không có error handling. Nên dùng lazy init function thay thế: export async function getConfig() với module-level cache bên trong, caller tự await.
Câu 3
Worker fixture sharedData: { count: number } (worker scope). Test 1 chạy sharedData.count = 10. Test 2 chạy sau đó trong cùng worker — giá trị sharedData.count là bao nhiêu? Nếu test 1 được retry bởi worker mới, test 2 trong worker mới thấy gì?
Đáp án
Test 2 trong cùng worker thấy count = 10 — cùng instance. Khi test 1 fail và Playwright spawn worker mới, worker mới tạo instance sharedData mới với count = 0 — state của worker cũ không được kế thừa. Test 2 trong worker mới thấy count = 0 (trước khi test 1 retry chạy) hoặc count = 10 (nếu test 1 retry chạy trước test 2).
Câu 4
Khi nào nên dùng module-level lazy init thay vì worker fixture? Cho 1 ví dụ cụ thể.
Đáp án
Dùng module-level lazy init khi state là pure data không cần teardown: ví dụ parse file JSON config lớn, compile regex patterns, build lookup table từ fixture file. Không cần await use(), không cần fixture boilerplate. Ví dụ: let schema: JSONSchema | null = null; export function getSchema() { if (!schema) { schema = JSON.parse(readFileSync(...)); } return schema; } — gọi trong test, init 1 lần per worker, không cần cleanup.
Câu 5
Bạn muốn seed database 1 lần cho tất cả worker trước khi test chạy. Dùng beforeAll với flag file system có ổn không? Phương án nào tốt hơn?
Đáp án
Không ổn — beforeAll chạy trong worker, nhiều worker start cùng lúc có thể cùng check file tồn tại và cùng seed → duplicate data, conflict. Phương án tốt hơn: dùng globalSetup — chạy 1 lần trong 1 process trước khi tất cả worker được spawn, không có concurrency. Với globalSetup, seed chắc chắn xong trước khi test nào chạy.
Bài Tiếp Theo
Bài 69 giới thiệu --shard flag — cách chia bộ test thành nhiều phần chạy trên nhiều máy CI song song.
