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ẽ:
- Hiểu rõ code sau
await use(value)là cleanup phase — chạy sau test body, không phải ngay sau dòng đó. - Biết cleanup chạy cả khi test pass lẫn khi test fail.
- Áp dụng
try/finallyđể đảm bảo cleanup không bị bỏ qua dù fixture throw. - Hiểu thứ tự reverse cleanup khi nhiều fixture phụ thuộc nhau.
- Truy cập
testInfotrong cleanup để thực hiện conditional action (vd chụp screenshot khi test fail). - Phân biệt timing cleanup của test-scope fixture và worker-scope fixture.
- Tránh 4 pitfall hay gặp khi viết cleanup logic.
Cleanup Phase Là Gì
Trong fixture function, await use(value) là điểm phân chia hai giai đoạn:
- Trước
use()— setup phase: chạy trước test body, chuẩn bị và inject resource. - Sau
use()— cleanup phase: chạy sau test body kết thúc, dọn dẹp resource.
testUser: async ({ apiClient }, use) => {
// ── SETUP ────────────────────────────────────
const user = await apiClient.createUser({
email: `test-${Date.now()}@example.com`,
});
// ── USE — test body chạy ở đây ───────────────
await use(user);
// ── CLEANUP ──────────────────────────────────
await apiClient.deleteUser(user.id);
},
await use(user) "suspend" fixture execution: Playwright pass user vào test body và đợi test body hoàn thành. Khi test body xong (dù pass hay fail), Playwright resume fixture function — tiếp tục chạy code sau use().
Về mặt cơ chế, đây là async generator pattern: fixture function hoạt động như coroutine, dùng use() làm yield point thay vì yield của generator thực sự.
So sánh với afterEach
Cleanup trong fixture tương đương afterEach về mặt thời điểm chạy, nhưng có một số điểm khác biệt quan trọng:
| Fixture cleanup | afterEach hook | |
|---|---|---|
| Scope | Gắn với fixture cụ thể — chỉ chạy khi fixture được inject | Gắn với describe block hoặc file — chạy cho mọi test trong scope đó |
| Truy cập resource | Trực tiếp — biến user, client nằm trong closure |
Phải dùng biến ngoài scope (let user khai báo ở ngoài) |
| Reusability | Import fixture từ file khác — dùng lại mọi test file | Copy-paste hoặc extract helper function thủ công |
| Lazy execution | Chỉ chạy khi test request fixture đó | Luôn chạy cho mọi test trong scope |
Cleanup Chạy Khi Nào
Cleanup chạy trong cả hai trường hợp:
Test pass
// Test pass → cleanup vẫn chạy bình thường
test('create user and verify profile', async ({ testUser, page }) => {
await page.goto(`/users/${testUser.id}`);
await expect(page.getByRole('heading')).toContainText(testUser.name);
// Test pass → fixture cleanup chạy sau đây
});
// → apiClient.deleteUser(testUser.id) được gọi
Test fail
// Test fail → cleanup VẪN chạy
test('verify user email', async ({ testUser }) => {
expect(testUser.email).toBe('[email protected]'); // FAIL
// Test fail ở đây, nhưng fixture cleanup vẫn được gọi
});
// → apiClient.deleteUser(testUser.id) được gọi — không bị leak
Đây là lý do fixture cleanup đáng tin cậy hơn pattern let user; afterEach(() => deleteUser(user?.id)) — với hook, nếu test throw trước khi assign user, cleanup có thể không có gì để xóa nhưng cũng không throw (do optional chaining). Với fixture, biến user được tạo trong setup rồi mới use() — cleanup luôn có reference hợp lệ.
Thứ tự thực thi trong một test
1. fixture A setup
2. fixture B setup (depends on A)
3. TEST BODY chạy
4. fixture B cleanup ← reverse order
5. fixture A cleanup
try/finally Cho Safe Cleanup
Code sau await use() không chạy nếu use() throw exception. Để đảm bảo cleanup luôn chạy — kể cả khi test throw — dùng try/finally:
dbConnection: async ({}, use) => {
const client = await connectDB({
host: process.env.TEST_DB_HOST,
database: process.env.TEST_DB_NAME,
});
try {
await use(client);
} finally {
// Chạy dù use() throw hay không
await client.disconnect();
}
},
Playwright tự wrap cleanup khi test fail, nhưng try/finally bảo vệ thêm một lớp: nếu chính use() throw (rất hiếm, nhưng có thể do Playwright internal error), finally vẫn chạy.
Pattern multi-step cleanup
Khi resource cần nhiều bước cleanup theo thứ tự:
testEnv: async ({}, use, testInfo) => {
const env = await setupEnv({
name: `test-${testInfo.workerIndex}-${Date.now()}`,
});
try {
await use(env);
} finally {
// Cleanup theo thứ tự: flush trước, clear sau, disconnect cuối
await env.flushQueue();
await env.clearCache();
await env.disconnect();
}
},
Thứ tự bước cleanup trong finally do lập trình viên kiểm soát — Playwright không tự sắp xếp các bước này.
Khi nào cần try/finally, khi nào không
| Tình huống | Cần try/finally? |
|---|---|
| DB connection, file handle, network socket | Có — resource bên ngoài, leak tốn chi phí |
| Mock server, background process | Có — process zombie nếu không kill |
| Xóa test data trong DB | Có — dirty data ảnh hưởng test sau |
| In-memory state, local variable | Không cần — GC lo |
| Token authentication (tự expire) | Thường không cần |
Cleanup Order — Reverse
Khi test dùng nhiều fixture phụ thuộc nhau, Playwright cleanup theo reverse order: fixture nào setup cuối thì cleanup trước.
Ví dụ: fixture chain 3 cấp
export const test = base.extend<{
dbClient: DbClient;
apiClient: ApiClient;
testUser: TestUser;
}>({
dbClient: async ({}, use) => {
console.log('1. dbClient setup');
const client = await connectDB();
try {
await use(client);
} finally {
console.log('6. dbClient cleanup');
await client.disconnect();
}
},
apiClient: async ({ dbClient }, use) => {
console.log('2. apiClient setup');
const api = new ApiClient(dbClient);
try {
await use(api);
} finally {
console.log('5. apiClient cleanup');
await api.close();
}
},
testUser: async ({ apiClient }, use) => {
console.log('3. testUser setup');
const user = await apiClient.createUser();
try {
await use(user);
} finally {
console.log('4. testUser cleanup');
await apiClient.deleteUser(user.id);
}
},
});
Output thứ tự thực tế khi chạy test:
1. dbClient setup
2. apiClient setup
3. testUser setup
[TEST BODY]
4. testUser cleanup ← cleanup trước — setup cuối
5. apiClient cleanup
6. dbClient cleanup ← cleanup sau — setup đầu
Rule: cleanup luôn ngược với setup. Điều này đảm bảo dependency cleanup đúng thứ tự — testUser phải xóa trước khi apiClient đóng connection, và apiClient phải đóng trước khi dbClient disconnect.
Thứ tự cleanup toàn cục
Test body → test-scope fixture cleanup (reverse) → worker-scope fixture cleanup → browser close
Browser close do Playwright quản lý — không cần gọi thủ công trong fixture.
testInfo Trong Cleanup
Fixture function nhận tham số thứ ba là testInfo — object chứa metadata về test đang chạy. Trong cleanup phase, testInfo.status đã phản ánh kết quả test (passed, failed, timedOut, skipped), cho phép thực hiện conditional cleanup.
Chụp screenshot khi test fail
screenshotOnFail: async ({ page }, use, testInfo) => {
await use();
// testInfo.status available sau khi test body kết thúc
if (testInfo.status !== testInfo.expectedStatus) {
const screenshotPath = `failures/${testInfo.title.replace(/\s+/g, '-')}.png`;
await page.screenshot({ path: screenshotPath });
// Đính kèm vào test report
await testInfo.attach('screenshot-on-failure', {
path: screenshotPath,
contentType: 'image/png',
});
}
},
testInfo.expectedStatus thường là 'passed' — so sánh với testInfo.status để phát hiện test fail. Dùng cả timedOut nếu muốn capture cả timeout:
const failed = testInfo.status === 'failed' || testInfo.status === 'timedOut';
if (failed) {
await page.screenshot({ path: `failures/${testInfo.workerIndex}-${Date.now()}.png` });
}
Ghi log fixture finished
testUser: async ({ apiClient }, use, testInfo) => {
const user = await apiClient.createUser();
try {
await use(user);
} finally {
await apiClient.deleteUser(user.id);
// Log sau cleanup để confirm
console.log(
`[fixture] testUser cleaned up for "${testInfo.title}" — status: ${testInfo.status}`
);
}
},
testInfo.workerIndex cho unique naming
testInfo.workerIndex hữu ích trong cleanup khi cần tạo path/name unique cho mỗi worker để tránh conflict khi parallel:
tempDir: async ({}, use, testInfo) => {
const dir = `/tmp/pw-test-${testInfo.workerIndex}-${Date.now()}`;
await fs.mkdir(dir, { recursive: true });
try {
await use(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
},
Use Cases Thực Tế
Resource disposal — Close DB connection
dbClient: async ({}, use) => {
const client = new PgClient({ connectionString: process.env.TEST_DATABASE_URL });
await client.connect();
try {
await use(client);
} finally {
await client.end(); // disconnect bất kể test kết quả gì
}
},
Resource disposal — Kill mock server
mockApiServer: async ({}, use) => {
const server = await createMockServer({ port: 0 }); // port 0 = random
await server.listen();
try {
await use({ baseUrl: `http://localhost:${server.port}` });
} finally {
await server.close();
}
},
State reset — Clear mock data
withMockedTime: async ({ page }, use) => {
// Freeze clock tại một thời điểm cố định
await page.clock.setFixedTime(new Date('2026-01-15T10:00:00Z'));
await use();
// Reset về real time sau test
await page.clock.runFor(0); // flush pending timers
// page.clock.uninstall() nếu muốn restore hoàn toàn
},
Test data cleanup — Delete user và order
testOrder: async ({ apiClient, testUser }, use) => {
// Setup: tạo order cho testUser
const order = await apiClient.createOrder({
userId: testUser.id,
items: [{ productId: 'prod-001', qty: 2 }],
});
try {
await use(order);
} finally {
// Cleanup order trước (dependency của testUser)
// testUser fixture sẽ tự cleanup user sau
await apiClient.cancelOrder(order.id);
await apiClient.deleteOrder(order.id);
}
},
Conditional cleanup dựa vào test result
preserveOnFail: async ({}, use, testInfo) => {
const tempFile = `/tmp/debug-${testInfo.workerIndex}.json`;
await use(tempFile);
if (testInfo.status === 'passed') {
// Chỉ xóa file khi test pass — giữ lại để debug khi fail
await fs.unlink(tempFile).catch(() => {});
} else {
console.log(`[debug] Preserved temp file: ${tempFile}`);
}
},
Worker-Scope Cleanup
Worker-scope fixture (scope: 'worker') có timing cleanup khác với test-scope:
- Test-scope: cleanup chạy sau mỗi test.
- Worker-scope: cleanup chạy một lần khi worker exit — sau test cuối cùng trong worker đó hoàn thành.
export const test = base.extend<
{}, // TestFixtures
{ sharedDbPool: DbPool } // WorkerFixtures
>({
sharedDbPool: [async ({}, use) => {
// Setup: tạo connection pool — chạy một lần khi worker khởi động
const pool = await createDbPool({
connectionString: process.env.TEST_DATABASE_URL,
max: 5,
});
console.log(`Worker pool created — max connections: 5`);
try {
await use(pool);
} finally {
// Cleanup: chạy KHI WORKER EXIT — sau tất cả test trong worker
await pool.end();
console.log('Worker pool closed');
}
}, { scope: 'worker' }],
});
Worker có thể chạy 10, 20, hay nhiều test — pool được tạo một lần, dùng chung cho tất cả, cleanup một lần khi worker tắt.
testInfo trong worker-scope fixture
Worker-scope fixture KHÔNG nhận testInfo là tham số thứ ba. testInfo chỉ available trong test-scope fixture vì nó gắn với test cụ thể. Worker-scope fixture không biết test nào đang chạy.
// SAI — worker-scope fixture không có testInfo
sharedDb: [async ({}, use, testInfo) => { // testInfo là undefined
// ...
}, { scope: 'worker' }],
// ĐÚNG — test-scope fixture mới có testInfo
dbClient: async ({}, use, testInfo) => {
console.log(`Creating DB client for test: ${testInfo.title}`);
// ...
},
Cleanup Throw Error
Nếu cleanup code throw exception, Playwright sẽ report lỗi đó — nhưng điều này có thể mask test failure gốc: test fail vì một assertion, nhưng cleanup error che đi lỗi đó trong report.
Vấn đề: cleanup throw che mất test failure
// Cleanup throw → report chỉ thấy cleanup error, không thấy test assertion fail
testUser: async ({ apiClient }, use) => {
const user = await apiClient.createUser();
await use(user);
await apiClient.deleteUser(user.id); // Nếu dòng này throw NetworkError...
// ...report sẽ thấy NetworkError thay vì lỗi assertion của test
},
Best practice: log error, không throw
testUser: async ({ apiClient }, use) => {
const user = await apiClient.createUser();
try {
await use(user);
} finally {
try {
await apiClient.deleteUser(user.id);
} catch (err) {
// Ghi log để debug nhưng không throw
// → test result gốc được giữ nguyên
console.error(`[fixture] Cleanup failed for user ${user.id}:`, err);
}
}
},
Pattern nested try/catch trong finally: outer try/finally đảm bảo cleanup luôn được attempt, inner try/catch đảm bảo cleanup error không propagate lên che test failure.
Khi nào nên để cleanup throw
Cleanup throw có thể chấp nhận được khi cleanup failure là critical indicator — ví dụ test infrastructure bị hỏng (DB unreachable), cần dừng toàn bộ suite sớm. Nhưng đây là trường hợp ngoại lệ, không phải mặc định.
Giới Hạn Cần Biết
SIGKILL — Cleanup không guaranteed
Cleanup chạy trong cùng Node.js process. Nếu process bị kill bằng SIGKILL (force kill, OOM killer, hay CI infrastructure kill), cleanup không chạy. Playwright không có cơ chế rescue cleanup sau SIGKILL.
Với resource quan trọng (DB records, external service state), cần thêm cleanup mechanism bên ngoài — ví dụ: job định kỳ xóa test data cũ hơn 24h, hoặc transaction rollback ở DB level.
Timeout cleanup
Cleanup chạy trong fixture timeout — cùng budget với setup. Mặc định là test timeout (30s). Cleanup chậm (vd: chờ queue drain, xóa nhiều record) dễ vượt timeout:
// Cleanup chậm → timeout fixture
testEnv: async ({}, use) => {
const env = await setupEnv();
await use(env);
await env.drainAllQueues(); // Nếu dòng này mất >25s → timeout!
},
Giải pháp: tăng fixture timeout cho fixture cụ thể hoặc tối ưu cleanup không block lâu.
testEnv: [async ({}, use) => {
const env = await setupEnv();
await use(env);
await env.drainAllQueues();
}, { timeout: 60_000 }], // timeout riêng cho fixture này
Side-effect ngoài process
Cleanup không thể undo side-effect đã commit ra ngoài process: email đã gửi, Stripe charge đã tạo, S3 object đã upload. Với các hành động không reversible, test nên mock/stub thay vì hit production service.
Common Pitfalls
1. Quên await use() — cleanup không bao giờ chạy
// SAI — use() không được gọi
testUser: async ({ apiClient }, use) => {
const user = await apiClient.createUser();
// Quên: await use(user);
await apiClient.deleteUser(user.id); // dòng này chạy ngay, TRƯỚC test body
},
// ĐÚNG
testUser: async ({ apiClient }, use) => {
const user = await apiClient.createUser();
await use(user); // test body chạy ở đây
await apiClient.deleteUser(user.id); // cleanup sau test
},
Kết quả: user bị xóa trước khi test chạy — test nhận object user đã không còn tồn tại trong DB. Lỗi thường biểu hiện dưới dạng 404 hoặc assertion fail ngẫu nhiên khó debug.
2. Cleanup throw che mất original test failure
// SAI — cleanup throw masking test failure
testUser: async ({ apiClient }, use) => {
const user = await apiClient.createUser();
await use(user);
await apiClient.deleteUser(user.id); // throw NetworkError
// → test report chỉ thấy NetworkError, không thấy assertion fail gốc
},
// ĐÚNG — wrap cleanup bằng try/catch
testUser: async ({ apiClient }, use) => {
const user = await apiClient.createUser();
try {
await use(user);
} finally {
try {
await apiClient.deleteUser(user.id);
} catch (err) {
console.error('[fixture] cleanup error:', err);
}
}
},
3. Cleanup tương tác với UI sau khi page đã đóng
// SAI — page có thể đã close khi cleanup chạy
screenshotFixture: async ({ page }, use) => {
await use();
await page.screenshot({ path: 'cleanup.png' }); // Error: page already closed
},
// ĐÚNG — kiểm tra trước khi interact với page
screenshotFixture: async ({ page }, use, testInfo) => {
await use();
if (testInfo.status !== 'passed') {
try {
await page.screenshot({ path: `failures/${testInfo.title}.png` });
} catch {
// page có thể đã đóng nếu test bị timeout
}
}
},
4. Cleanup chậm vượt fixture timeout
// SAI — không estimate cleanup time
heavyCleanup: async ({}, use) => {
const env = await setupLargeEnv(); // 5s setup
await use(env);
await env.cleanupAllData(); // 28s cleanup → vượt 30s timeout!
},
// ĐÚNG — set fixture timeout phù hợp hoặc giới hạn cleanup
heavyCleanup: [async ({}, use) => {
const env = await setupLargeEnv();
await use(env);
// Cleanup với timeout riêng
await Promise.race([
env.cleanupAllData(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Cleanup timeout')), 25_000)
),
]).catch(err => console.error('[fixture] Cleanup partial:', err));
}, { timeout: 60_000 }],
Tổng Kết
- Code sau
await use(value)là cleanup phase — chạy sau test body hoàn thành, dù test pass hay fail. - Cleanup chạy theo reverse setup order: fixture setup cuối thì cleanup trước.
- try/finally là best practice cho resource quan trọng — đảm bảo cleanup chạy kể cả khi
use()throw. - testInfo tham số thứ ba của fixture — available trong cleanup phase, chứa
status,title,workerIndexđể conditional cleanup. - Worker-scope fixture cleanup chạy một lần khi worker exit, không phải sau mỗi test. Worker-scope fixture không nhận testInfo.
- Cleanup throw exception mask test failure gốc — best practice: log error, không throw trong cleanup.
- Cleanup không guaranteed nếu process bị SIGKILL. Cleanup chạy trong fixture timeout budget.
Bài Tập Củng Cố
Câu 1
Fixture sau có vấn đề gì? Sửa lại cho đúng:
testRecord: async ({ dbClient }, use) => {
const record = await dbClient.insert('records', { name: 'test' });
await dbClient.delete('records', record.id); // cleanup
await use(record);
},
Đáp án
Cleanup đặt TRƯỚC use() thay vì sau. Kết quả: record bị xóa trước khi test body chạy — test nhận record object nhưng record đó không còn trong DB. Sửa lại:
testRecord: async ({ dbClient }, use) => {
const record = await dbClient.insert('records', { name: 'test' });
try {
await use(record); // test body chạy ở đây
} finally {
await dbClient.delete('records', record.id); // cleanup sau test
}
},
Câu 2
Fixture A setup trước, B depend vào A, C depend vào B. Test dùng C. Liệt kê thứ tự setup và cleanup.
Đáp án
Setup theo dependency order: A → B → C. Cleanup theo reverse: C → B → A.
Setup: A → B → C
[TEST BODY]
Cleanup: C → B → A
Playwright tự resolve dependency graph và đảm bảo thứ tự này. Không cần viết thủ công.
Câu 3
Viết fixture screenshotOnFail không inject value (dùng await use()), nhưng trong cleanup phase: nếu test fail, chụp screenshot page và attach vào testInfo report.
Đáp án
type MyFixtures = {
screenshotOnFail: void;
};
export const test = base.extend<MyFixtures>({
screenshotOnFail: async ({ page }, use, testInfo) => {
await use();
if (testInfo.status !== testInfo.expectedStatus) {
const name = testInfo.title.replace(/[^a-z0-9]/gi, '-');
const path = `failures/${name}-${testInfo.workerIndex}.png`;
try {
await page.screenshot({ path, fullPage: true });
await testInfo.attach('screenshot', {
path,
contentType: 'image/png',
});
} catch (err) {
console.error('[screenshotOnFail] failed:', err);
}
}
},
});
Dùng try/catch bên trong cleanup vì page có thể đã close nếu test timeout. Wrap tên file bằng replace để tránh ký tự đặc biệt trong path.
Câu 4
Worker-scope fixture cleanup chạy khi nào? Tại sao worker-scope fixture không nhận testInfo?
Đáp án
Worker-scope fixture cleanup chạy một lần khi worker exit — sau khi tất cả test được assign cho worker đó hoàn thành. Không chạy sau mỗi test.
Worker-scope fixture không nhận testInfo vì testInfo là object gắn với một test cụ thể — nó không tồn tại ở level worker. Khi worker-scope fixture cleanup chạy, không còn test nào đang active để lấy metadata từ đó.
Câu 5
Cleanup của fixture testOrder throw NetworkError. Test body đã fail vì expect(order.status).toBe('confirmed') không đúng. Playwright sẽ report gì? Làm thế nào để giữ test failure gốc?
Đáp án
Khi cleanup throw, Playwright thường report cleanup error — test failure gốc (assertion fail) có thể bị che hoặc cả hai cùng report tùy version. Trong mọi trường hợp, report trở nên khó đọc.
Để giữ test failure gốc: wrap cleanup trong try/catch, log error mà không throw:
testOrder: async ({ apiClient }, use) => {
const order = await apiClient.createOrder();
try {
await use(order);
} finally {
try {
await apiClient.deleteOrder(order.id);
} catch (err) {
console.error('[fixture] deleteOrder failed:', err);
// Không throw → test failure gốc được giữ nguyên trong report
}
}
},
Bài Tiếp Theo
Bài 29 trình bày pattern apiClient fixture — đóng gói HTTP request client với authentication, base URL và error handling vào fixture tái sử dụng.
