Mục lục
- Mục Tiêu Bài Học
- Glob/Regex vs Predicate Runtime — Điểm Khác Biệt
- Cấu Trúc Predicate Handler
- Match Theo Request Body (
postDataJSON) - Match Theo Query String
- Match Theo HTTP Method
- Match Theo Header
- Count-Based Mock — Test Retry Logic
- Stateful Mock — Cart / Wishlist
- Kết Hợp Với
route.fetch() - Use Cases Thực Tế
- 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ẽ:
- Hiểu sự khác biệt giữa URL pattern match (glob/regex) và predicate runtime trong route handler.
- Viết predicate match theo request body, query string, header, HTTP method.
- Dùng count-based mock để test retry logic (fail lần 1, pass lần 2).
- Xây dựng stateful mock mô phỏng giỏ hàng (cart) qua nhiều request POST/GET.
- Kết hợp predicate với
route.fetch()để mock có chọn lọc, pass-through còn lại. - Tránh bốn pitfall phổ biến gây request hang hoặc state leak giữa các test.
Glob/Regex vs Predicate Runtime — Điểm Khác Biệt
Glob và regex pattern trong page.route hoạt động ở tầng URL: Playwright so sánh URL string của từng request với pattern đã đăng ký, và nếu khớp thì gọi handler — bất kể body, method hay header của request là gì.
Conditional mocking dùng cùng page.route với glob/regex (thường là glob bắt toàn bộ URL), nhưng bên trong handler mới đọc request object tại runtime và quyết định hành động:
| Chiều match | Glob/Regex | Predicate runtime |
|---|---|---|
| URL path | Có | Có (qua glob) |
| HTTP Method | Không | Có |
| Request body | Không | Có |
| Query param | Có (glob **) |
Có (đọc chính xác từng param) |
| Header | Không | Có |
| Lần gọi thứ N | Không | Có (count-based) |
| State tích lũy | Không | Có (stateful mock) |
Nói cách khác: glob/regex quyết định route nào được đăng ký, predicate quyết định response nào được trả cho từng request cụ thể trong cùng route đó.
Cấu Trúc Predicate Handler
Một route handler nhận hai tham số: route (Route object — để fulfill/continue/abort) và request (Request object — để đọc thông tin). Handler có thể bỏ qua tham số thứ hai vì route.request() trả về cùng object đó:
await page.route('**/api/users', async (route) => {
const request = route.request();
// Đọc thông tin request tại runtime
const method = request.method(); // 'GET', 'POST', ...
const headers = request.headers(); // { 'content-type': '...', ... }
const postData = request.postDataJSON(); // parsed JSON body hoặc null
const url = new URL(request.url()); // URL object để đọc searchParams
// Quyết định mock hay pass-through
if (/* điều kiện */) {
await route.fulfill({ json: mockData });
} else {
await route.continue(); // Pass-through — KHÔNG được bỏ qua
}
});
Nguyên tắc bắt buộc: Mọi nhánh trong handler phải kết thúc bằng một trong bốn lệnh: route.fulfill(), route.continue(), route.abort(), hoặc route.fallback(). Nếu handler kết thúc mà không gọi lệnh nào, request sẽ hang vĩnh viễn và test timeout.
Match Theo Request Body (postDataJSON)
Use case điển hình: cùng endpoint POST /api/users nhưng trả dữ liệu khác nhau tùy vào payload — ví dụ trả admin users khi body có role: 'admin', còn các request khác pass-through đến server thật.
const adminUsers = [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'admin' },
];
await page.route('**/api/users', async (route) => {
const postData = route.request().postDataJSON();
if (postData?.role === 'admin') {
await route.fulfill({ json: adminUsers });
} else {
await route.continue(); // Pass-through cho các request khác
}
});
postDataJSON() trả null khi:
- Request không có body (GET, HEAD, DELETE không có body).
- Body không phải JSON (form-data, text/plain, binary).
- Body là JSON nhưng
Content-Typeheader thiếuapplication/json.
Luôn dùng optional chaining (postData?.role) hoặc guard if (postData !== null) trước khi đọc property. Nếu không, truy cập property trên null throw exception và request hang.
Với body không phải JSON (ví dụ form-encoded), dùng request.postData() — trả string thô — rồi parse thủ công:
const raw = route.request().postData(); // 'name=Alice&role=admin'
const params = new URLSearchParams(raw ?? '');
const role = params.get('role'); // 'admin'
Match Theo Query String
Query param phù hợp để test các scenario đặc biệt của search/filter — ví dụ query ?q=empty trả kết quả trống, query ?q=error trigger lỗi 500, còn các query khác pass-through.
await page.route('**/api/search**', async (route) => {
const url = new URL(route.request().url());
const query = url.searchParams.get('q');
if (query === 'empty') {
await route.fulfill({ json: { results: [] } });
} else if (query === 'error') {
await route.fulfill({ status: 500, json: { message: 'Internal error' } });
} else {
await route.continue();
}
});
Glob **/api/search** — dấu ** cuối bắt cả phần ?q=... trong URL. Nếu dùng **/api/search (không có ** cuối), một số trường hợp route sẽ không match URL có query string tùy engine glob.
Pattern này cũng hữu ích cho pagination: match page=1 trả data đầu, page=2 trả data tiếp theo, còn lại pass-through:
await page.route('**/api/items**', async (route) => {
const page = new URL(route.request().url()).searchParams.get('page');
if (page === '1') {
await route.fulfill({ json: { items: firstPage, total: 30 } });
} else if (page === '2') {
await route.fulfill({ json: { items: secondPage, total: 30 } });
} else {
await route.continue();
}
});
Match Theo HTTP Method
REST API thường dùng cùng URL cho nhiều method: GET /api/item/1 lấy data, DELETE /api/item/1 xóa, PUT /api/item/1 cập nhật. Mock riêng từng method trên cùng route:
await page.route('**/api/item/*', async (route) => {
const method = route.request().method();
if (method === 'DELETE') {
// Mock delete thành công, không cần hit server
await route.fulfill({ status: 204 });
} else if (method === 'PUT') {
const body = route.request().postDataJSON();
await route.fulfill({ json: { ...body, updatedAt: '2026-05-28' } });
} else {
// GET và các method khác pass-through
await route.continue();
}
});
request.method() trả chuỗi uppercase: 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'. So sánh case-sensitive — luôn dùng uppercase.
Dùng pattern này để test delete confirmation flow mà không cần backend xử lý thật, hoặc để test optimistic update (UI cập nhật ngay khi gửi PUT mà chưa cần response).
Match Theo Header
request.headers() trả object plain với tất cả header lowercase. Dùng để phân biệt request từ các context khác nhau — ví dụ mock response theo Accept-Language, hoặc trả lỗi 401 khi thiếu Authorization:
await page.route('**/api/profile', async (route) => {
const headers = route.request().headers();
const auth = headers['authorization'];
if (!auth || !auth.startsWith('Bearer ')) {
await route.fulfill({ status: 401, json: { error: 'Unauthorized' } });
return;
}
// Token hợp lệ — pass-through đến server thật
await route.continue();
});
Hoặc mock theo ngôn ngữ để test i18n:
await page.route('**/api/config', async (route) => {
const lang = route.request().headers()['accept-language'] ?? 'en';
if (lang.startsWith('vi')) {
await route.fulfill({ json: { locale: 'vi', currency: 'VND' } });
} else {
await route.fulfill({ json: { locale: 'en', currency: 'USD' } });
}
});
Lưu ý: Header name trong request.headers() luôn lowercase (theo HTTP/2 convention). 'Authorization' sẽ không match — phải dùng 'authorization'.
Count-Based Mock — Test Retry Logic
Count-based pattern dùng biến đếm bên ngoài handler để biết đây là lần gọi thứ mấy. Use case chính: verify app có retry khi gặp lỗi thoáng qua (transient error).
test('retries after 500 error', async ({ page }) => {
let callCount = 0;
await page.route('**/api/data', async (route) => {
callCount++;
if (callCount === 1) {
// Lần đầu: trả 500 để kích hoạt retry
await route.fulfill({ status: 500, json: { message: 'Server error' } });
} else {
// Lần thứ 2 trở đi: pass-through hoặc fulfill thành công
await route.continue();
}
});
await page.goto('/dashboard');
// App phải hiển thị data cuối cùng dù lần đầu fail
await expect(page.getByTestId('data-table')).toBeVisible();
// Verify đã có ít nhất 2 lần gọi (1 fail + 1 retry)
expect(callCount).toBeGreaterThanOrEqual(2);
});
Biến callCount khai báo trong scope của test(), không phải ngoài describe block. Điều này đảm bảo mỗi test có counter riêng — counter không bị chia sẻ giữa các test chạy parallel hoặc sequential.
Nếu app có exponential backoff, test cần test.setTimeout() đủ lớn để cover thời gian chờ giữa các retry.
Stateful Mock — Cart / Wishlist
Stateful mock duy trì state trong closure variable qua nhiều request. Ví dụ điển hình: mock giỏ hàng — POST thêm item, GET trả toàn bộ giỏ, DELETE xóa item:
test('cart operations work end-to-end', async ({ page }) => {
let cart: Array<{ id: number; name: string; qty: number }> = [];
await page.route('**/api/cart', async (route) => {
const method = route.request().method();
if (method === 'POST') {
const item = route.request().postDataJSON();
cart.push(item);
await route.fulfill({ json: cart });
} else if (method === 'GET') {
await route.fulfill({ json: cart });
} else if (method === 'DELETE') {
const { id } = route.request().postDataJSON() ?? {};
cart = cart.filter(item => item.id !== id);
await route.fulfill({ json: cart });
} else {
await route.continue();
}
});
await page.goto('/shop');
// Thêm sản phẩm
await page.getByTestId('add-to-cart-1').click();
await page.getByTestId('add-to-cart-2').click();
// Mở giỏ hàng
await page.getByTestId('cart-icon').click();
// Phải có 2 item
await expect(page.getByTestId('cart-item')).toHaveCount(2);
// Xóa item đầu
await page.getByTestId('remove-item-1').click();
// Còn 1 item
await expect(page.getByTestId('cart-item')).toHaveCount(1);
});
Biến cart sống trong closure của test function — mỗi test có cart riêng, không bị lẫn với test khác. Toàn bộ flow (add, view, remove) được test mà không cần backend, và response luôn nhất quán với action trước đó.
Pattern tương tự áp dụng cho wishlist, notification list, hoặc bất cứ resource nào có CRUD operations.
Kết Hợp Với route.fetch()
route.fetch() gửi request thật đến server rồi trả về Response object — cho phép đọc response thật trước khi quyết định có sửa không. Kết hợp với predicate: một số request mock, còn lại fetch real và trả nguyên:
await page.route('**/api/data', async (route) => {
const request = route.request();
const params = new URL(request.url()).searchParams;
const scenario = params.get('scenario');
if (scenario === 'error') {
// Mock lỗi — không cần hit server
await route.fulfill({ status: 500, json: { message: 'Mocked error' } });
return;
}
if (scenario === 'empty') {
await route.fulfill({ json: { items: [], total: 0 } });
return;
}
// Mọi query khác: fetch thật, trả nguyên response
const response = await route.fetch();
await route.fulfill({ response });
});
route.fetch() sử dụng cùng options của request gốc (method, body, headers) trừ khi override. Sau khi gọi route.fetch(), bắt buộc phải gọi route.fulfill({ response }) — không được gọi route.continue(), vì request đã được fetch rồi.
Pattern này phù hợp khi test cần một số scenario đặc biệt (error, empty, timeout) nhưng scenario còn lại cần data thật từ server staging.
Use Cases Thực Tế
- Test error scenario cụ thể theo query
- Thay vì mock toàn bộ endpoint thành lỗi, chỉ mock khi
?q=errorhoặc?id=999— các request bình thường vẫn pass-through. Tránh ảnh hưởng đến các test khác không cần mock lỗi. - Test phân trang (pagination)
- Mock dữ liệu cố định theo từng giá trị
pageparam. Đảm bảo UI render đúng số trang, nút next/prev disabled đúng lúc, không phụ thuộc vào dữ liệu động của server. - Test retry với transient failure
- Count-based mock: lần 1 trả 500, lần 2 pass-through. Verify app có cơ chế retry và user cuối cùng thấy data, không thấy màn hình lỗi.
- Stateful flow: cart, wishlist, notification
- Stateful mock cho phép test toàn bộ flow CRUD (add → view → remove) trong một test duy nhất mà không cần reset database hay dùng fixture phức tạp.
- Test role-based UI
- Mock theo
roletrong request body: trả admin data khirole: 'admin', trả user data khirole: 'user'. Cùng test suite chạy nhiều scenario mà không cần nhiều auth fixture. - Test i18n
- Mock config endpoint theo
Accept-Languageheader. Mỗi test set header khác nhau, mock trả locale tương ứng.
Common Pitfalls
1. Quên route.continue() ở nhánh không match — request hang
Khi handler có điều kiện if/else if nhưng không có else (hoặc else không gọi continue()), request rơi vào nhánh không xử lý sẽ hang vĩnh viễn. Test sẽ timeout với lỗi mơ hồ, khó debug.
// SAI — thiếu else
await page.route('**/api/data', async (route) => {
if (condition) {
await route.fulfill({ json: mockData });
}
// Request không match condition sẽ hang!
});
// ĐÚNG
await page.route('**/api/data', async (route) => {
if (condition) {
await route.fulfill({ json: mockData });
} else {
await route.continue(); // Luôn có fallback
}
});
2. Stateful variable khai báo ngoài test — leak giữa các test
Nếu cart hay callCount được khai báo ngoài test() (ví dụ trong describe() hoặc ở top-level), biến sẽ tích lũy state qua nhiều test. Test sau nhìn thấy state của test trước — nguyên nhân gây flaky test và false positive.
// SAI — biến shared giữa tests
describe('cart', () => {
let cart = []; // Reset mỗi describe, không reset mỗi test!
test('add item', async ({ page }) => { /* ... */ });
test('remove item', async ({ page }) => { /* cart không empty! */ });
});
// ĐÚNG — biến trong từng test
test('add item', async ({ page }) => {
let cart = [];
// ...
});
3. postDataJSON() trả null không được guard — throw exception
GET request và DELETE request không body đều khiến postDataJSON() trả null. Truy cập property trực tiếp trên null throw TypeError: Cannot read properties of null, handler crash, request hang.
// SAI
const role = route.request().postDataJSON().role; // Crash nếu GET request
// ĐÚNG
const body = route.request().postDataJSON();
const role = body?.role; // undefined nếu null, không crash
4. Count variable shared scope khi test chạy parallel — race condition
Khi callCount sống ngoài test() và các test chạy parallel (nhiều worker), các test đồng thời increment cùng biến, dẫn đến count không chính xác và behavior khó đoán. Playwright chạy mỗi worker trên process riêng, nhưng trong cùng một worker, sequential tests trong cùng describe block chia sẻ closure. Giải pháp: luôn khai báo state variable bên trong test().
Tổng Kết
Conditional mocking mở rộng glob/regex pattern bằng cách đưa logic quyết định vào trong handler — tại runtime, với đầy đủ thông tin request. Năm chiều match chính: request body (postDataJSON), query string (URL.searchParams), HTTP method (request.method()), header (request.headers()), và lần gọi thứ N (biến đếm). Stateful mock thêm khả năng duy trì state qua nhiều request trong một test.
Quy tắc vận hành:
- Mọi nhánh trong handler phải kết thúc bằng fulfill/continue/abort/fallback.
- State variable phải nằm trong scope của
test(), không phải describe hay module. - Guard
postDataJSON()trước khi đọc property. - Sau
route.fetch(), dùngroute.fulfill({ response })— không dùngcontinue().
Bài Tập Củng Cố
Câu 1. Handler sau có vấn đề gì?
await page.route('**/api/users', async (route) => {
const body = route.request().postDataJSON();
if (body.type === 'admin') {
await route.fulfill({ json: [] });
} else {
await route.continue();
}
});
Xem đáp án
body có thể là null (khi request không có body, ví dụ GET). Truy cập body.type sẽ throw TypeError. Sửa: if (body?.type === 'admin').
Câu 2. Dùng API nào để đọc giá trị query param page từ URL https://example.com/api/items?page=2&limit=10 trong route handler?
Xem đáp án
const url = new URL(route.request().url());
const page = url.searchParams.get('page'); // '2'
Câu 3. Trong count-based mock, nếu khai báo let callCount = 0 bên ngoài test() (trong describe block), điều gì xảy ra khi hai test chạy tuần tự trong cùng worker?
Xem đáp án
Test thứ hai thừa hưởng giá trị callCount từ test trước (không reset về 0). Nếu test 1 gọi API 2 lần, test 2 bắt đầu với callCount = 2, khiến hành vi mock sai so với kỳ vọng.
Câu 4. Sau khi gọi const response = await route.fetch(), bước tiếp theo bắt buộc là gì? Sai bước này gây ra lỗi gì?
Xem đáp án
Phải gọi await route.fulfill({ response }). Nếu không gọi fulfill, request hang (browser đang chờ response nhưng handler kết thúc mà không trả gì). Không được gọi route.continue() sau route.fetch() — request đã được gửi rồi, continue sẽ gửi lần thứ hai.
Câu 5. Viết route handler cho **/api/products**: nếu query param category=sale thì trả { items: saleItems }; nếu category=new thì trả { items: newItems }; còn lại pass-through. Đảm bảo không có nhánh hang.
Xem đáp án
await page.route('**/api/products**', async (route) => {
const category = new URL(route.request().url()).searchParams.get('category');
if (category === 'sale') {
await route.fulfill({ json: { items: saleItems } });
} else if (category === 'new') {
await route.fulfill({ json: { items: newItems } });
} else {
await route.continue();
}
});
Bài Tiếp Theo
Bài 123 — unrouteAll({ behavior: 'wait' }) — trình bày cách hủy đăng ký toàn bộ route handler và kiểm soát hành vi khi còn request đang in-flight.
