Mục lục
Mục Tiêu Bài Học
Bài này không lặp lại cú pháp cơ bản của beforeAll/afterAll (đã có bài 33-34). Focus vào:
- Hiểu chính xác
test.describe.configure({ mode: 'serial' })làm gì với worker allocation và thứ tự thực thi. - Nắm behavior skip-on-fail khi test fail trong serial describe.
- Biết cách chia sẻ state giữa các test trong serial describe một cách có kiểm soát.
- Phân biệt serial mode của describe với
fullyParallel: falseở project level. - Hiểu retry behavior trong serial mode — retry test nào, skip test nào.
- Xác định khi nào serial mode là lựa chọn phù hợp, khi nào là anti-pattern.
Cú Pháp Và Vị Trí Khai Báo
test.describe.configure() phải được gọi trong đầu describe callback, trước khi khai báo bất kỳ test nào:
import { test, expect } from '@playwright/test';
test.describe('Order checkout flow', () => {
test.describe.configure({ mode: 'serial' });
test('add to cart', async ({ page }) => {
await page.goto('/shop');
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByRole('status')).toContainText('1 item');
});
test('go to checkout', async ({ page }) => {
await page.goto('/cart');
await page.getByRole('link', { name: 'Checkout' }).click();
await expect(page).toHaveURL('/checkout');
});
test('confirm order', async ({ page }) => {
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page).toHaveURL('/order-confirmation');
});
});
Một số điểm cần lưu ý về khai báo:
test.describe.configure()áp dụng cho describe block chứa nó — không ảnh hưởng describe cha hay describe anh em.- Có thể khai báo ở file level (không trong describe nào) để áp dụng cho toàn file.
- Nếu khai báo ở file level, mọi describe trong file đều chạy serial — nhưng các file khác không bị ảnh hưởng.
// Áp dụng toàn file — mọi test trong file này chạy serial
import { test, expect } from '@playwright/test';
test.describe.configure({ mode: 'serial' });
test('step 1', async ({ page }) => { /* ... */ });
test('step 2', async ({ page }) => { /* ... */ });
test('step 3', async ({ page }) => { /* ... */ });
Behavior Cơ Bản — Thứ Tự Và Worker
Khi serial mode được bật cho một describe, Playwright thực hiện 3 điều:
- Chạy theo thứ tự khai báo — test 1 hoàn thành rồi test 2 mới bắt đầu, không có parallelism trong describe đó.
- Giao cho 1 worker duy nhất — toàn bộ describe block được assign cho cùng 1 worker process. Không có worker khác nhận test từ describe này.
- Stop-on-first-fail — nếu 1 test fail, các test còn lại trong describe được đánh dấu
expected-skippedvà không chạy.
Điều này khác với chế độ parallel mặc định:
| Đặc điểm | Parallel (mặc định) | Serial mode |
|---|---|---|
| Thứ tự thực thi | Không xác định — phụ thuộc worker availability | Đúng thứ tự khai báo trong file |
| Worker allocation | Nhiều worker có thể nhận test từ cùng file | 1 worker duy nhất cho toàn describe |
| Khi test fail | Các test còn lại tiếp tục chạy (trừ khi --max-failures) |
Các test sau trong describe skip |
| Throughput | Cao — tận dụng đa nhân | Thấp hơn — tuần tự |
Lưu ý: "1 worker" ở đây chỉ có nghĩa là describe đó không bị phân mảnh qua nhiều worker. Các describe khác trong cùng project vẫn chạy song song trên worker riêng.
Skip-on-Fail — Cascade Skip
Đây là behavior quan trọng nhất của serial mode. Khi test thứ N fail, Playwright mark mọi test từ N+1 trở đi trong describe là expected-skipped — không phải skipped thông thường.
test.describe('Onboarding wizard', () => {
test.describe.configure({ mode: 'serial' });
test('step 1: fill profile', async ({ page }) => {
await page.goto('/onboarding/step-1');
await page.getByLabel('Name').fill('Alice');
await page.getByRole('button', { name: 'Next' }).click();
await expect(page).toHaveURL('/onboarding/step-2'); // Nếu fail tại đây
});
test('step 2: upload avatar', async ({ page }) => {
// Bị SKIP — không chạy vì step 1 fail
await page.setInputFiles('input[type="file"]', 'avatar.png');
});
test('step 3: confirm', async ({ page }) => {
// Bị SKIP — không chạy
await page.getByRole('button', { name: 'Finish' }).click();
});
});
Sự khác biệt giữa expected-skipped và skipped:
skipped: test bị skip chủ động (dùngtest.skip()) — được tính là "OK", không làm suite fail.expected-skipped: test bị bỏ qua do cascade từ serial fail — HTML report hiển thị rõ nguyên nhân, không làm suite fail thêm nhưng cho biết có test không được kiểm tra.
Hệ quả thực tế: nếu step 1 fail vì một lý do không liên quan (network fluke, timeout), step 2 và 3 không được kiểm tra dù logic của chúng hoàn toàn ổn. Đây là một trong những lý do Playwright khuyến cáo hạn chế serial mode.
Share State Qua Serial
Vì serial describe chạy trên 1 worker duy nhất, có thể dùng biến closure để truyền state giữa các test. Pattern phổ biến nhất kết hợp serial với beforeAll/afterAll để share page và dữ liệu:
import { test, expect, type Page } from '@playwright/test';
test.describe('User registration flow', () => {
test.describe.configure({ mode: 'serial' });
let page: Page;
let userId: string;
test.beforeAll(async ({ browser }) => {
// Tạo 1 page dùng xuyên suốt toàn describe
page = await browser.newPage();
});
test.afterAll(async () => {
await page.close();
});
test('step 1: signup', async () => {
// Không dùng fixture { page } — dùng page từ closure
await page.goto('/signup');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('Secure123!');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL('/verify-email');
// Capture userId từ URL để dùng ở test sau
const url = new URL(page.url());
userId = url.searchParams.get('userId') ?? '';
expect(userId).not.toBe('');
});
test('step 2: verify email', async () => {
// page vẫn ở trạng thái từ step 1
await page.goto(`/verify?userId=${userId}&token=test-token`);
await expect(page).toHaveURL('/dashboard');
});
test('step 3: complete profile', async () => {
await page.getByRole('link', { name: 'Complete profile' }).click();
await page.getByLabel('Display name').fill('Alice');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Profile updated')).toBeVisible();
});
});
Điểm cần chú ý trong pattern này:
- Test body dùng
async ()— không destructure{ page }từ fixture vì đang dùngpagetừ closure. browsertrongbeforeAlllà worker-scope fixture — hợp lệ vớibeforeAll(khácpagelà test-scope).- Nếu step 1 fail trước khi set
userId, step 2 sẽ skip — tránh được lỗi "userId is empty" không liên quan. afterAllđóng page để cleanup — không để browser leak.
Nếu không cần share page mà chỉ cần share data, cách đơn giản hơn:
test.describe('API resource lifecycle', () => {
test.describe.configure({ mode: 'serial' });
let resourceId: number;
test('create resource', async ({ request }) => {
const res = await request.post('/api/resources', {
data: { name: 'test-item', type: 'A' },
});
expect(res.status()).toBe(201);
resourceId = (await res.json()).id;
});
test('update resource', async ({ request }) => {
const res = await request.patch(`/api/resources/${resourceId}`, {
data: { name: 'test-item-updated' },
});
expect(res.status()).toBe(200);
});
test('delete resource', async ({ request }) => {
const res = await request.delete(`/api/resources/${resourceId}`);
expect(res.status()).toBe(204);
});
});
Pattern này dùng fixture request bình thường — mỗi test vẫn có fixture riêng, chỉ share resourceId qua biến closure.
Serial vs fullyParallel: false
Hai cơ chế này đều làm test chạy tuần tự, nhưng phạm vi và ý nghĩa khác nhau hoàn toàn:
| Đặc điểm | fullyParallel: false |
describe.configure({ mode: 'serial' }) |
|---|---|---|
| Phạm vi | Toàn project — mọi file, mọi describe | Chỉ describe block hoặc file khai báo nó |
| Khai báo ở đâu | playwright.config.ts |
Trong file test, đầu describe callback |
| Các file khác | Cũng bị ảnh hưởng — chạy serial | Không ảnh hưởng — vẫn parallel bình thường |
| Skip-on-fail | Không tự động — vẫn chạy hết | Có — cascade skip test sau fail |
| Worker allocation | Mặc định 1 test/worker tại một thời điểm trong file | 1 worker cố định cho toàn describe |
// playwright.config.ts
export default defineConfig({
// fullyParallel: false — mọi file chạy serial với nhau
// Mặc định là false nếu không set
fullyParallel: false,
});
Với fullyParallel: false (mặc định), các test trong cùng file chạy tuần tự, nhưng nhiều file vẫn có thể chạy song song. Khi set fullyParallel: true, test trong cùng file có thể chạy song song qua nhiều worker — và lúc đó describe.configure({ mode: 'serial' }) là cách opt-out cho một describe cụ thể cần tuần tự.
Use case thực tế: project dùng fullyParallel: true để tối đa throughput, nhưng có 1-2 describe cần serial vì lý do resource contention:
// playwright.config.ts
export default defineConfig({
fullyParallel: true, // Toàn project parallel
workers: 8,
});
// license-activation.spec.ts
test.describe('License activation', () => {
// Override: describe này serial vì chỉ có 1 license slot
test.describe.configure({ mode: 'serial' });
test('activate license', async ({ page }) => { /* ... */ });
test('verify activated features', async ({ page }) => { /* ... */ });
test('deactivate license', async ({ page }) => { /* ... */ });
});
// checkout.spec.ts — vẫn chạy parallel bình thường
test('checkout with visa', async ({ page }) => { /* ... */ });
test('checkout with paypal', async ({ page }) => { /* ... */ });
Retry Behavior Trong Serial Mode
Khi retries được cấu hình trong playwright.config.ts, retry behavior trong serial describe hoạt động như sau:
- Test N fail → Playwright retry test N (không retry từ đầu describe).
- Retry fail → các test N+1 trở đi bị
expected-skipped. - Retry pass → các test N+1 trở đi tiếp tục chạy bình thường.
// playwright.config.ts
export default defineConfig({
retries: 2, // Retry tối đa 2 lần khi fail
});
// Ví dụ timeline với retries: 2 và serial describe
test.describe('Payment flow', () => {
test.describe.configure({ mode: 'serial' });
test('add payment method', async ({ page }) => {
// Attempt 1: fail (network timeout)
// Attempt 2 (retry 1): pass ← tiếp tục
});
test('verify payment method', async ({ page }) => {
// Chạy bình thường — test trước pass sau retry
});
test('make payment', async ({ page }) => {
// Chạy bình thường
});
});
So sánh với --max-failures=N CLI flag:
--max-failures=N: dừng toàn bộ test run sau N test fail — ảnh hưởng mọi file, mọi describe.serial: chỉ dừng (skip) các test sau trong cùng describe — run tổng thể vẫn tiếp tục.
Khi retry xảy ra trong serial describe, state từ lần chạy trước không được reset tự động. Nếu test fail đã làm thay đổi state bên ngoài (database, file system), retry có thể gặp state bẩn. Cần beforeEach cleanup hoặc test thiết kế idempotent để tránh vấn đề này.
Khi Nào Nên Dùng Serial Mode
Serial mode có một số use case hợp lý — tất cả đều liên quan đến ràng buộc kỹ thuật không thể tránh, không phải vì tiện hơn:
1. UI flow tuần tự không thể tách
Wizard nhiều bước mà state server-side không thể reset giữa test:
// Onboarding wizard 4 bước — server lưu progress, không có API reset
test.describe('KYC verification wizard', () => {
test.describe.configure({ mode: 'serial' });
test('step 1: upload ID document', async ({ page }) => { /* ... */ });
test('step 2: selfie capture', async ({ page }) => { /* ... */ });
test('step 3: address verification', async ({ page }) => { /* ... */ });
test('step 4: review and submit', async ({ page }) => { /* ... */ });
});
2. External resource singleton
Resource bên ngoài chỉ cho phép 1 connection hoặc 1 active session:
// Hệ thống chỉ có 1 admin session active tại một thời điểm
test.describe('Admin console actions', () => {
test.describe.configure({ mode: 'serial' });
test('lock account', async ({ page }) => { /* ... */ });
test('verify account locked', async ({ page }) => { /* ... */ });
test('unlock account', async ({ page }) => { /* ... */ });
});
3. Migration hoặc data pipeline tuần tự
Khi test kiểm tra một chuỗi thao tác trên cùng 1 record và thứ tự có ý nghĩa:
// Test chuỗi CRUD trên cùng 1 entity
test.describe('Document lifecycle', () => {
test.describe.configure({ mode: 'serial' });
let docId: string;
test('create draft', async ({ request }) => {
const res = await request.post('/api/docs', { data: { title: 'Draft' } });
docId = (await res.json()).id;
});
test('publish document', async ({ request }) => {
await request.patch(`/api/docs/${docId}`, { data: { status: 'published' } });
});
test('archive document', async ({ request }) => {
await request.patch(`/api/docs/${docId}`, { data: { status: 'archived' } });
});
});
4. Setup một lần tốn kém, dùng xuyên nhiều bước
Khi setup (ví dụ: spin up service, seed database lớn) quá chậm để làm per-test, nhưng cần nhiều bước kiểm tra:
test.describe('ML model inference', () => {
test.describe.configure({ mode: 'serial' });
let modelHandle: string;
test.beforeAll(async ({ request }) => {
// Load model vào memory — tốn 30s
const res = await request.post('/api/models/load', { data: { model: 'v3' } });
modelHandle = (await res.json()).handle;
});
test.afterAll(async ({ request }) => {
await request.post('/api/models/unload', { data: { handle: modelHandle } });
});
test('inference: text classification', async ({ request }) => { /* ... */ });
test('inference: sentiment analysis', async ({ request }) => { /* ... */ });
test('inference: named entity recognition', async ({ request }) => { /* ... */ });
});
Anti-Pattern Và Khuyến Cáo Playwright
Playwright docs chính thức xếp serial mode vào danh sách anti-pattern khi bị lạm dụng. Lý do kỹ thuật:
- Test isolation vi phạm: test tốt là test có thể chạy độc lập theo bất kỳ thứ tự nào. Serial describe ngầm nói rằng "test 2 cần test 1 chạy trước" — đây là coupling giữa test.
- Debug khó hơn: khi test 2 fail, nguyên nhân có thể ở test 1 hoặc chính test 2. Cascade skip che khuất thông tin — không biết test 3 có pass không nếu test 2 không chạy.
- Fragile suite: flaky test ở vị trí đầu chuỗi serial sẽ làm cả chuỗi sau skip, dù bản thân test đó chỉ flaky chứ không broken.
- Scale kém: 1 worker cho toàn describe, không tận dụng được multi-core — suite lớn dùng nhiều serial sẽ chạy chậm.
Thay vì serial, Playwright khuyến nghị:
- Test isolated: mỗi test tự setup state cần thiết, không dựa vào test trước.
- API setup: dùng
requestfixture trongbeforeEachđể tạo state qua API — nhanh hơn UI flow và độc lập. - Storage state: dùng
storageStateđể reuse auth state thay vì login qua UI trong từng test (bài riêng về storage state). - Fixture setup: đặt logic setup phức tạp vào fixture để tái dùng và kiểm soát lifecycle tốt hơn.
// Anti-pattern: serial để "login một lần" rồi test nhiều thứ
test.describe('Logged in tests', () => {
test.describe.configure({ mode: 'serial' });
test('login', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
// ... login steps
});
test('view dashboard', async ({ page }) => {
// page không có session từ test trước — fixture page mới per test!
await page.goto('/dashboard'); // Sẽ redirect về /login
});
});
// Đúng: dùng storageState hoặc authedPage fixture
// Login state được setup đúng cách trước mỗi test
Lưu ý trong ví dụ anti-pattern trên: dù serial mode, mỗi test vẫn nhận fixture page mới (test-scope). Share page qua serial cần dùng biến closure + beforeAll như đã trình bày ở phần 5 — đây cũng là lý do pattern này phức tạp và dễ sai.
Pitfalls
1. Nhầm tưởng fixture page được share tự động
// SAI — serial mode KHÔNG share page giữa test
test.describe('Checkout flow', () => {
test.describe.configure({ mode: 'serial' });
test('add to cart', async ({ page }) => {
await page.goto('/shop');
await page.getByRole('button', { name: 'Add to cart' }).click();
// page này sẽ bị destroy sau test này
});
test('checkout', async ({ page }) => {
// page mới — cart trống, không có state từ test trước
await page.goto('/checkout'); // Sẽ fail hoặc redirect
});
});
Serial mode đảm bảo thứ tự và 1 worker, nhưng mỗi test vẫn nhận fixture page mới. Để share page thực sự, phải dùng biến closure + beforeAll({ browser }).
2. Quên cleanup shared state — leak qua run
// SAI — không có afterAll cleanup
test.describe('Resource lifecycle', () => {
test.describe.configure({ mode: 'serial' });
let resourceId: number;
// Không có afterAll để delete resource
// Mỗi lần chạy tạo thêm resource trong DB
test('create', async ({ request }) => {
const res = await request.post('/api/items');
resourceId = (await res.json()).id;
});
test('verify', async ({ request }) => {
const res = await request.get(`/api/items/${resourceId}`);
expect(res.status()).toBe(200);
});
});
// ĐÚNG: thêm afterAll cleanup
test.afterAll(async ({ request }) => {
if (resourceId) {
await request.delete(`/api/items/${resourceId}`);
}
});
3. Flaky test đầu chuỗi gây cascade skip sai
Nếu test 1 trong serial describe là flaky (đôi khi pass, đôi khi fail vì timing), mỗi lần fail nó sẽ skip test 2-3. Debug report sẽ thấy test 2-3 là "expected-skipped" — dễ nhầm tưởng vấn đề ở test 2-3 trong khi thực ra ở test 1.
Cách nhận biết: nếu skip lý do luôn là "serial predecessor failed", nguyên nhân ở test trước đó.
4. Serial trong file có fullyParallel: true — không nhầm lẫn
// playwright.config.ts: fullyParallel: true
// file: mixed.spec.ts
test.describe('Normal tests', () => {
// Chạy parallel — OK
test('A', async ({ page }) => { /* ... */ });
test('B', async ({ page }) => { /* ... */ });
});
test.describe('Serial section', () => {
test.describe.configure({ mode: 'serial' });
// Chỉ section này serial — các describe khác không ảnh hưởng
test('X', async ({ page }) => { /* ... */ });
test('Y', async ({ page }) => { /* ... */ });
});
Đây là behavior đúng và intentional — không phải bug. describe.configure chỉ affect describe chứa nó.
5. Retry với shared state bẩn
Khi test N fail và được retry, nếu test N đã tạo state bên ngoài (vd: tạo record trong DB trước khi assert), retry sẽ gặp record đó vẫn còn — có thể gây lỗi duplicate hoặc unexpected state. Thiết kế test idempotent hoặc dùng beforeEach để cleanup trước mỗi lần chạy:
test.describe('Order flow', () => {
test.describe.configure({ mode: 'serial' });
let orderId: string;
test.beforeEach(async ({ request }) => {
// Cleanup order nếu đã tồn tại — idempotent
if (orderId) {
await request.delete(`/api/orders/${orderId}`).catch(() => {});
orderId = '';
}
});
test('create order', async ({ request }) => {
const res = await request.post('/api/orders', { data: { item: 'X' } });
orderId = (await res.json()).id;
});
// ...
});
Tổng Kết
test.describe.configure({ mode: 'serial' })ép describe chạy tuần tự theo thứ tự khai báo, trên 1 worker duy nhất.- Test fail trong serial describe → các test sau bị đánh dấu
expected-skippedvà không chạy (cascade skip). - Serial mode không tự share
pagefixture — mỗi test vẫn nhận fixture mới. Share page cần biến closure +beforeAll({ browser }). - Khác
fullyParallel: false: serial describe chỉ affect describe đó, còn lại vẫn parallel. - Retry: test fail → retry test đó; retry pass → chuỗi tiếp tục; retry fail → cascade skip.
- Dùng khi có ràng buộc kỹ thuật thực sự: wizard không thể tách, resource singleton, CRUD lifecycle.
- Tránh dùng serial chỉ để "tiện" — vi phạm isolation, debug khó, scale kém.
- Luôn có
afterAllcleanup khi share state — tránh leak qua run.
Quiz
Câu 1. Một serial describe có 4 test. Test thứ 2 fail, retries = 1. Retry test 2 cũng fail. Kết quả cuối cùng của 4 test là gì?
Đáp án
Test 1: passed. Test 2: failed (đã retry 1 lần, vẫn fail). Test 3: expected-skipped. Test 4: expected-skipped. Cascade skip chỉ xảy ra sau khi retry cũng fail — không skip ngay sau lần fail đầu.
Câu 2. File A có test.describe.configure({ mode: 'serial' }) ở đầu file (không trong describe nào). File B trong cùng project không có configure. Chạy npx playwright test với fullyParallel: true. File B ảnh hưởng thế nào?
Đáp án
File B không bị ảnh hưởng — vẫn chạy parallel bình thường. describe.configure trong file A chỉ áp dụng cho file A. fullyParallel: true vẫn có hiệu lực với file B.
Câu 3. Serial describe có 3 test, mỗi test dùng fixture { page }. Test 1 navigate đến /dashboard rồi kết thúc. Test 2 bắt đầu — page.url() trả về gì?
Đáp án
about:blank (hoặc URL ban đầu của page mới). Serial mode không share fixture page — mỗi test nhận một page instance mới. State navigation từ test 1 không tồn tại trong test 2.
Câu 4. Playwright docs khuyến cáo tránh serial mode vì lý do nào? Nêu ít nhất 2 lý do kỹ thuật.
Đáp án
Hai trong số các lý do: (1) Vi phạm test isolation — test phụ thuộc vào test khác, không thể chạy độc lập; (2) Cascade skip gây khó debug — test fail ở vị trí đầu che khuất tất cả test sau, không biết test sau có vấn đề không; (3) Throughput thấp hơn — chỉ 1 worker cho describe; (4) Flaky test đầu chuỗi gây skip toàn bộ phần còn lại dù không liên quan.
Câu 5. Cần test chuỗi create → update → delete trên cùng 1 record API. Thiết kế serial describe đúng cách: biến nào cần khai báo, cleanup ở đâu, test body nhận fixture gì?
Đáp án
Khai báo biến let recordId: string trong describe scope (closure). Test body nhận fixture { request } bình thường (không cần share). afterAll dùng { request } để delete record nếu recordId đã được set (guard bằng if (recordId) để tránh lỗi khi create test fail). Không cần beforeAll vì không share page.
