Mục lục
- Mục Tiêu Bài Học
- Scope Là Gì Trong Custom Fixture
- Default Behavior — Function Form
- Explicit Declaration — Tuple Form
- Vòng Đời Chi Tiết
- Isolation Giữa Các Test
- Use Cases Điển Hình
- testInfo Trong Fixture
- Dependency Rules Giữa Scope
- Performance Và Khi Nào Không Dùng test-scope
- Common Pitfalls
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
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 tại sao scope
testlà default và khi nào nó hoạt động đúng với function form. - Phân biệt function form và tuple form — biết khi nào cần tuple.
- Mô tả chính xác thứ tự setup → test body → teardown của một test-scope fixture.
- Nhận biết 4 use case chuẩn cho test-scope: counter, DB transaction, mock route, temp file.
- Áp dụng đúng dependency rules: fixture nào được phép depend vào fixture nào.
- Tránh được 4 pitfall điển hình với test-scope fixture.
Cú pháp base.extend() và cấu trúc fixture function đã được trình bày ở bài 19. Bài này không lặp lại phần đó — focus vào scope mechanics.
Scope Là Gì Trong Custom Fixture
Scope của fixture quyết định đơn vị sống của instance đó — tức là Playwright Test sẽ tạo instance mới khi nào và giữ nguyên instance cũ đến khi nào.
Playwright Test hỗ trợ hai scope cho custom fixture:
| Scope | Tạo instance khi | Huỷ instance khi | Số instance tối đa |
|---|---|---|---|
test |
Trước mỗi test | Sau mỗi test | 1 per test (trong 1 worker) |
worker |
Lần đầu fixture được dùng trong worker | Khi worker kết thúc toàn bộ công việc | 1 per worker process |
Bài này tập trung vào scope test. Worker scope được trình bày ở bài 21.
Default Behavior — Function Form
Khi khai báo fixture bằng function form — tức là truyền trực tiếp async function — Playwright Test mặc định hiểu scope là test:
import { test as base } from '@playwright/test';
class Counter {
value = 0;
increment() { this.value++; }
reset() { this.value = 0; }
}
export const test = base.extend<{ counter: Counter }>({
// Function form — scope mặc định là 'test'
counter: async ({}, use) => {
const c = new Counter();
await use(c); // test body chạy ở đây
c.reset(); // cleanup sau test
},
});
Không có gì để khai báo thêm — function form đủ dùng cho mọi trường hợp scope test thông thường. Đây là cú pháp gọn nhất và phổ biến nhất.
Explicit Declaration — Tuple Form
Tuple form là cú pháp khai báo fixture dưới dạng mảng 2 phần tử: [async function, options object]. Nó cần thiết khi muốn đặt bất kỳ option nào ngoài scope mặc định — ví dụ auto, timeout, title, hoặc khai báo fixture là option.
export const test = base.extend<{ counter: Counter }>({
counter: [
async ({}, use) => {
const c = new Counter();
await use(c);
c.reset();
},
{ scope: 'test' }, // explicit — nhưng giống behavior khi dùng function form
],
});
Khai báo scope: 'test' trong tuple khi fixture đã default là test scope không thay đổi gì về runtime. Lý do dùng explicit trong trường hợp này:
- Tăng tính tường minh cho codebase lớn, nhiều fixture với scope khác nhau.
- Khi fixture cần thêm option khác (ví dụ
timeout), tuple form là bắt buộc, và viết rõscopecùng lúc là good practice.
Options object trong tuple form nhận các key:
| Key | Kiểu | Mô tả ngắn |
|---|---|---|
scope |
'test' | 'worker' |
Scope của fixture |
auto |
boolean |
Tự động khởi tạo dù test không destructure (bài 22) |
option |
boolean |
Đánh dấu là configurable option (bài 23) |
timeout |
number |
Timeout riêng cho setup + teardown của fixture này |
title |
string |
Tên fixture hiển thị trong trace / reporter |
box |
boolean |
Ẩn fixture internals khỏi trace viewer [v1.46+] |
Bài này chỉ đề cập scope. Các key còn lại được trình bày ở bài 22 (auto) và bài 23 (option).
Vòng Đời Chi Tiết
Với test-scope fixture, lifecycle trong một test diễn ra như sau:
1. [Worker] worker-scope fixtures khởi tạo (nếu có) — chỉ 1 lần
─────────────────────────────────────────────────────────────
2. [Test N] test-scope fixture setup chạy → trước await use(c)
3. [Test N] await use(c) — yield: test body bắt đầu chạy
4. [Test N] test body hoàn thành (pass hoặc fail)
5. [Test N] use(c) return — code sau use(c) chạy → teardown
─────────────────────────────────────────────────────────────
6. [Test N+1] test-scope fixture setup chạy lại — instance mới
7. [Test N+1] await use(c) — yield: test body chạy
8. [Test N+1] teardown
─────────────────────────────────────────────────────────────
9. [Worker] worker-scope fixtures teardown — khi worker xong
Điểm quan trọng cần nhớ:
- Teardown luôn chạy — ngay cả khi test fail. Code sau
await use()được Playwright Test đảm bảo chạy. - Instance mới — mỗi test nhận object hoàn toàn mới từ setup, không kế thừa state từ test trước.
- Thứ tự teardown ngược với setup — nếu fixture A depend vào fixture B, B setup trước, A teardown trước.
// Fixture với log để quan sát lifecycle
export const test = base.extend<{ counter: Counter }>({
counter: async ({}, use) => {
console.log('[fixture] setup counter');
const c = new Counter();
await use(c); // test body chạy đây
console.log('[fixture] teardown counter, final value:', c.value);
c.reset();
},
});
test('test A', async ({ counter }) => {
console.log('[test A] counter.value =', counter.value); // 0
counter.increment();
console.log('[test A] after increment =', counter.value); // 1
});
test('test B', async ({ counter }) => {
console.log('[test B] counter.value =', counter.value); // 0 — fresh
});
// Output thứ tự (trong 1 worker, sequential):
// [fixture] setup counter
// [test A] counter.value = 0
// [test A] after increment = 1
// [fixture] teardown counter, final value: 1
// [fixture] setup counter
// [test B] counter.value = 0
// [fixture] teardown counter, final value: 0
Isolation Giữa Các Test
Test-scope fixture đảm bảo state không lan sang test khác. Ví dụ minh hoạ rõ nhất:
test('test 1', async ({ counter }) => {
expect(counter.value).toBe(0); // fresh instance
counter.increment();
counter.increment();
expect(counter.value).toBe(2);
});
test('test 2', async ({ counter }) => {
// Nhận instance mới — không phải instance từ test 1
expect(counter.value).toBe(0); // fresh again, không phải 2
});
test('test 3', async ({ counter }) => {
expect(counter.value).toBe(0); // luôn 0, bất kể test nào chạy trước
});
Behaviour trên đúng bất kể thứ tự chạy test (parallel hay sequential). Mỗi worker nhận fixture instance riêng của mình cho mỗi test — không có shared mutable state giữa test trong cùng worker qua test-scope fixture.
Use Cases Điển Hình
1. Counter / State object per test
Use case đơn giản nhất: bất kỳ object nào cần trạng thái khởi tạo sạch cho từng test.
export const test = base.extend<{ counter: Counter }>({
counter: async ({}, use) => {
await use(new Counter());
// Không cần cleanup vì Counter không giữ external resource
},
});
2. Mock API server per test
Khi mỗi test cần mock server riêng với endpoint khác nhau:
import { createServer, Server } from 'http';
type MockServer = { url: string; server: Server };
export const test = base.extend<{ mockServer: MockServer }>({
mockServer: async ({}, use) => {
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
});
await new Promise<void>(resolve => server.listen(0, resolve));
const port = (server.address() as any).port;
await use({ url: `http://localhost:${port}`, server });
await new Promise<void>(resolve => server.close(() => resolve()));
},
});
3. Database transaction wrap
Pattern phổ biến trong integration test: mở transaction trước test, rollback sau — đảm bảo DB luôn sạch giữa các test mà không cần truncate bảng.
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const test = base.extend<{ db: PoolClient }>({
db: async ({}, use) => {
const client = await pool.connect();
await client.query('BEGIN');
await use(client); // test thực hiện queries trên client này
await client.query('ROLLBACK'); // huỷ mọi thay đổi sau test
client.release();
},
});
Pattern DB transaction sẽ được đào sâu hơn ở chương về Database Testing. Ở đây chỉ nêu cấu trúc cơ bản để minh hoạ use case test-scope.
4. Temporary file per test
import { mkdtemp, rm } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
export const test = base.extend<{ tmpDir: string }>({
tmpDir: async ({}, use) => {
const dir = await mkdtemp(join(tmpdir(), 'pw-test-'));
await use(dir); // test dùng thư mục tạm này
await rm(dir, { recursive: true, force: true }); // dọn dẹp
},
});
5. Mock route per test
Khi cần intercept request chỉ trong phạm vi 1 test (page fixture có scope test — có thể depend trực tiếp):
export const test = base.extend<{ mockApi: void }>({
mockApi: async ({ page }, use) => {
await page.route('/api/user', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ name: 'Test User', role: 'admin' }),
});
});
await use(); // void fixture — không có value trả về
await page.unroute('/api/user');
},
});
page.unroute() trong teardown đảm bảo route không bị để lại — quan trọng khi fixture page được reuse (ví dụ trong test.beforeEach hay fixture chain).
testInfo Trong Fixture
Fixture function nhận tham số thứ ba: testInfo — object chứa metadata về test đang chạy. Chỉ có trong test-scope fixture (không có trong worker-scope).
export const test = base.extend<{ counter: Counter }>({
counter: async ({}, use, testInfo) => {
console.log('Setting up for:', testInfo.title);
// testInfo.title = tên test đang chạy
// testInfo.file = đường dẫn file spec
// testInfo.line = dòng khai báo test
const c = new Counter();
await use(c);
// Sau test — có thể đọc kết quả
if (testInfo.status !== testInfo.expectedStatus) {
console.log(`[counter] Test "${testInfo.title}" failed with counter=${c.value}`);
}
},
});
Các property hữu ích nhất của testInfo trong teardown:
testInfo.status:'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted'— trạng thái thực tế sau khi test chạy.testInfo.expectedStatus: thường là'passed', trừ khi test được annotate vớitest.fail().testInfo.outputDir: thư mục output dành riêng cho test này — hữu ích để lưu file debug.testInfo.attach(): đính kèm file hoặc buffer vào test report — có thể gọi trong teardown để attach debug artifact khi fail.
// Attach debug info khi test fail
export const test = base.extend<{ db: PoolClient }>({
db: async ({}, use, testInfo) => {
const client = await pool.connect();
await client.query('BEGIN');
await use(client);
if (testInfo.status === 'failed') {
// Lưu SQL query log để debug
const log = await client.query('SELECT * FROM pg_stat_activity');
await testInfo.attach('db-state-on-fail.json', {
body: JSON.stringify(log.rows, null, 2),
contentType: 'application/json',
});
}
await client.query('ROLLBACK');
client.release();
},
});
Dependency Rules Giữa Scope
Fixture có thể depend vào fixture khác qua destructuring trong tham số đầu tiên. Nhưng scope tạo ra ràng buộc:
| Fixture A scope | Depend vào fixture B scope | Hợp lệ? | Lý do |
|---|---|---|---|
test |
test |
OK | Cùng vòng đời |
test |
worker |
OK | Worker tồn tại lâu hơn — dùng chung được |
worker |
test |
ERROR | Worker không thể depend thứ thay đổi mỗi test |
worker |
worker |
OK | Cùng vòng đời |
// OK — test depend worker
export const test = base.extend<TestFixtures, WorkerFixtures>({
// dbPool là worker-scope (kết nối pool mở 1 lần per worker)
dbPool: [
async ({}, use) => {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await use(pool);
await pool.end();
},
{ scope: 'worker' },
],
// db là test-scope — depend vào dbPool (worker-scope) → OK
db: async ({ dbPool }, use) => {
const client = await dbPool.connect();
await client.query('BEGIN');
await use(client);
await client.query('ROLLBACK');
client.release();
},
});
// ERROR — worker depend test
export const test = base.extend<TestFixtures, WorkerFixtures>({
counter: async ({}, use) => { // test-scope
await use(new Counter());
},
// Lỗi TypeScript + runtime error khi chạy
workerCounter: [
async ({ counter }, use) => { // worker depend test-scope counter → INVALID
await use(counter);
},
{ scope: 'worker' },
],
});
// TypeError: "workerCounter" fixture with a 'worker' scope
// cannot depend on "counter" fixture with a 'test' scope
Playwright Test phát hiện lỗi này tại runtime với thông báo rõ ràng — không phải silent fail.
Performance Và Khi Nào Không Dùng test-scope
Test-scope fixture được tạo lại cho mỗi test — đây là overhead có thể đo được nếu setup tốn thời gian.
Setup fixture: 200ms
Tests: 50 test
Tổng overhead: 50 × 200ms = 10 giây thuần fixture setup
(chưa tính teardown)
Mốc tham khảo thực tế:
| Loại setup | Thời gian điển hình | Dùng test-scope? |
|---|---|---|
| Khởi tạo object đơn giản (Counter, DTO) | < 1ms | Tốt |
| Đọc file nhỏ, parse JSON | 1–5ms | Tốt |
| HTTP request (mock server local) | 5–20ms | Chấp nhận được |
| DB connection mới (không pool) | 50–200ms | Nên dùng pool + worker-scope cho pool |
| Khởi động process ngoài (browser, server) | 500ms – 5s | Cần worker-scope hoặc beforeAll |
Nguyên tắc: nếu setup fixture mất hơn 50ms và test suite có nhiều hơn 20 test dùng fixture đó, cân nhắc tách phần heavy setup sang worker-scope, chỉ giữ phần per-test (transaction, route) ở test-scope.
Common Pitfalls
1. Setup nặng trong test-scope — suite chậm ngầm
// SAI — mở DB connection mới mỗi test (không pool)
db: async ({}, use) => {
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect(); // ~100–200ms per test
await use(client);
await client.end();
},
// TỐT HƠN — pool ở worker-scope, chỉ checkout client ở test-scope
dbPool: [async ({}, use) => {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await use(pool);
await pool.end();
}, { scope: 'worker' }],
db: async ({ dbPool }, use) => {
const client = await dbPool.connect(); // ~1–5ms (pool reuse)
await use(client);
client.release();
},
2. Cleanup throw error — che khuất lỗi gốc từ test
// SAI — nếu server.close() throw, lỗi gốc từ test bị mất
mockServer: async ({}, use) => {
const server = startServer();
await use(server);
server.close(); // nếu throw ở đây → override error gốc
},
// ĐÚNG — wrap teardown để preserve original error
mockServer: async ({}, use) => {
const server = startServer();
await use(server);
try {
await new Promise<void>(resolve => server.close(() => resolve()));
} catch (e) {
console.error('[fixture] server close error (suppressed):', e);
}
},
3. Sai giá trị scope trong tuple — capitalize hoặc typo
// SAI — 'Test' (viết hoa) không phải scope hợp lệ
counter: [
async ({}, use) => { await use(new Counter()); },
{ scope: 'Test' }, // TypeError at runtime — Playwright không chấp nhận
],
// SAI — 'tests' (số nhiều) không hợp lệ
counter: [
async ({}, use) => { await use(new Counter()); },
{ scope: 'tests' }, // tương tự
],
// ĐÚNG — lowercase chính xác
counter: [
async ({}, use) => { await use(new Counter()); },
{ scope: 'test' },
],
TypeScript sẽ bắt lỗi này nếu type được khai báo đúng — scope có kiểu 'test' | 'worker'. Khi không có TypeScript, lỗi chỉ xuất hiện tại runtime.
4. Worker-scope fixture depend test-scope — TypeScript + runtime error
// SAI — worker không thể depend test-scope
sharedPool: [
async ({ db }, use) => { // db là test-scope
await use(db);
},
{ scope: 'worker' },
],
// Lỗi:
// "sharedPool" fixture with a 'worker' scope cannot depend on
// "db" fixture with a 'test' scope
Gặp lỗi này: kiểm tra type parameter thứ hai của base.extend<TestFixtures, WorkerFixtures> — fixture được đặt nhầm nhóm sẽ không enforce đúng scope.
Tổng Kết
- Scope
testlà mặc định — function form không cần khai báo gì thêm. - Tuple form
[async fn, options]cần khi muốn thêmauto,timeout,title,box, hoặcoption. - Mỗi test nhận instance fixture mới — state không lan từ test này sang test khác.
- Teardown (code sau
await use()) luôn chạy kể cả khi test fail. - Test-scope fixture có thể depend vào worker-scope fixture, nhưng không được chiều ngược lại.
- Tham số
testInfochỉ có trong test-scope fixture — dùng để log, attach artifact dựa trên kết quả test. - Setup tốn hơn 50ms × nhiều test: xem xét tách heavy part sang worker-scope.
- Cleanup phải xử lý exception riêng — để tránh che khuất lỗi gốc từ test.
Bài Tập Củng Cố
Câu 1
Cho đoạn code sau:
export const test = base.extend<{ list: string[] }>({
list: async ({}, use) => {
const items: string[] = [];
await use(items);
// không có cleanup
},
});
test('A', async ({ list }) => {
list.push('hello');
expect(list).toHaveLength(1);
});
test('B', async ({ list }) => {
expect(list).toHaveLength(0);
});
Test B có pass không? Tại sao?
Đáp án
Pass. Mỗi test nhận instance items mới từ const items: string[] = [] — test A push vào instance của test A, test B nhận array rỗng độc lập. Test-scope đảm bảo không có shared state giữa hai test.
Câu 2
Viết fixture tmpFile tạo file tạm thời với nội dung 'initial', sau test xoá file đó. Nếu test fail, attach path file vào report thay vì xoá.
Đáp án
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
export const test = base.extend<{ tmpFile: string }>({
tmpFile: async ({}, use, testInfo) => {
const filePath = join(tmpdir(), `pw-${Date.now()}.txt`);
await writeFile(filePath, 'initial', 'utf-8');
await use(filePath);
if (testInfo.status === 'failed') {
await testInfo.attach('tmp-file-on-fail', {
path: filePath,
contentType: 'text/plain',
});
} else {
await unlink(filePath).catch(() => {});
}
},
});
Câu 3
Fixture A có scope worker, Fixture B có scope test. Fixture A muốn dùng Fixture B — điều này có được không? Lỗi gì xảy ra?
Đáp án
Không được. Worker-scope fixture không thể depend vào test-scope fixture vì vòng đời mâu thuẫn: worker tồn tại suốt nhiều test, trong khi fixture B thay đổi mỗi test. Playwright Test sẽ throw runtime error: "A" fixture with a 'worker' scope cannot depend on "B" fixture with a 'test' scope. TypeScript cũng báo lỗi nếu type parameter của base.extend được khai báo đúng.
Câu 4
Đoạn code teardown sau có vấn đề gì?
server: async ({}, use) => {
const srv = await startHeavyServer();
await use(srv);
await srv.shutdown(); // có thể throw nếu server đã crash
},
Đáp án
Nếu srv.shutdown() throw — ví dụ server đã crash trong quá trình test — exception này sẽ override lỗi gốc từ test, làm mất thông tin debug quan trọng. Teardown nên wrap bằng try/catch và log lỗi riêng thay vì để nó propagate:
server: async ({}, use) => {
const srv = await startHeavyServer();
await use(srv);
try {
await srv.shutdown();
} catch (e) {
console.error('[fixture] server shutdown error:', e);
}
},
Câu 5
Khi nào nên dùng tuple form thay vì function form cho test-scope fixture?
Đáp án
Dùng tuple form khi cần ít nhất một trong các option: auto: true (tự khởi tạo dù test không destructure), timeout (đặt timeout riêng cho fixture), title (tên hiển thị trong trace/reporter), box: true (ẩn internals khỏi trace viewer), hoặc option: true (đánh dấu là configurable option). Nếu chỉ cần test-scope đơn giản, function form gọn hơn và đủ dùng.
Bài Tiếp Theo
Bài 21 đào sâu scope còn lại: worker — fixture tạo 1 lần per worker process, dùng chung cho mọi test trong worker đó. Đây là scope phù hợp cho heavy resources như database pool, browser instance, hoặc server nặng.
