Mục lục
- Mục Tiêu Bài Học
- Vấn Đề Cần Giải Quyết
- Cú Pháp Class APIClient
- Fixture Wrap APIClient
- Test Usage
- 4 Lợi Ích Cốt Lõi
- Pattern Extend Với Auth
- Combine Với testUser Fixture — Dependency Chain
- Pattern testEnvironment Helper
- Best Practices Phân Tách Domain
- So Sánh Với
requestFixture Low-level - Mock Pattern
- Limitations
- 4 Pitfalls
- Quiz + Bài Tiếp
Mục Tiêu Bài Học
- Hiểu tại sao gọi
request.post('/users', { data })trực tiếp trong test gây vấn đề khi scale. - Viết class
APIClientwrapAPIRequestContextvới methods semantic rõ ràng. - Tạo fixture
apiClientinject class này vào test quatest.extend(). - Extend class với auth header (
AuthedAPIClient). - Combine
apiClientvớitestUserfixture để tạo dependency chain cleanup tự động. - Xây dựng
testEnvhelper fixture cho seed data phức tạp. - Tránh 4 pitfall: mutable state, bỏ qua
res.ok(), hardcode baseURL, hardcode token.
Vấn Đề Cần Giải Quyết
Giả sử project có 20 test cần tạo user trước khi chạy. Cách đơn giản nhất:
// test A
const res = await request.post('/users', {
data: { email: '[email protected]', name: 'A' },
});
const user = await res.json();
// test B
const res = await request.post('/users', {
data: { email: '[email protected]', name: 'B' },
});
const user = await res.json();
// ... 18 test nữa
Vấn đề phát sinh khi scale:
- DRY violation: path
/users, option shape{ data }, xử lý response lặp lại ở mọi nơi. Khi API thay đổi (vd endpoint đổi thành/api/v2/users), cần sửa ở 20 chỗ. - Không type-safe:
datalàobjectbất kỳ — IDE không nhắc thiếu fieldemailhayname. - Error handling rải rác: mỗi test tự quyết định xử lý khi
res.ok() === false— một số bỏ qua, một số throw, một sốconsole.log. - Khó mock: test cần API server thật. Muốn chạy offline phải tìm và sửa từng chỗ gọi
request.post.
Pattern API Client Fixture giải quyết cả 4 điểm này bằng cách trừu tượng hóa HTTP call vào một class, rồi inject class đó qua fixture.
Cú Pháp Class APIClient
File api-client.ts — class thuần TypeScript, không import gì từ @playwright/test ngoài type:
// api-client.ts
import type { APIRequestContext } from '@playwright/test';
export class APIClient {
constructor(
private request: APIRequestContext,
private baseURL: string,
) {}
async createUser(data: { email: string; name: string }) {
const res = await this.request.post(`${this.baseURL}/users`, { data });
if (!res.ok()) throw new Error(`Create user failed: ${res.status()}`);
return await res.json();
}
async deleteUser(id: string) {
const res = await this.request.delete(`${this.baseURL}/users/${id}`);
if (!res.ok()) throw new Error(`Delete user failed: ${res.status()}`);
}
async listUsers() {
const res = await this.request.get(`${this.baseURL}/users`);
return await res.json();
}
}
Điểm chú ý về thiết kế class:
APIRequestContextimport dưới dạngtype(chỉ type-level import) — không kéo theo runtime dependency không cần thiết.baseURLnhận từ constructor, không hardcode trong class — cho phép tái cấu hình cho môi trường khác nhau.- Mỗi method kiểm tra
res.ok()và throw rõ ràng với status code — lỗi sẽ bắn lên test dưới dạng exception có message mô tả, không phải silent fail. - Return
res.json()thay vìAPIResponseobject — test nhận data trực tiếp, không cầnawait res.json()thêm lần nữa.
Fixture Wrap APIClient
File fixtures.ts — dùng test.extend() để tạo fixture apiClient:
// fixtures.ts
import { test as base } from '@playwright/test';
import { APIClient } from './api-client';
export const test = base.extend<{
apiClient: APIClient;
}>({
apiClient: async ({ request, baseURL }, use) => {
const client = new APIClient(request, baseURL!);
await use(client);
// Không có cleanup đặc biệt ở đây —
// request fixture tự cleanup bởi Playwright sau test
},
});
export { expect } from '@playwright/test';
Fixture nhận hai built-in fixtures làm dependency: request (APIRequestContext) và baseURL (string từ config). Điều này đảm bảo APIClient hoạt động đồng nhất với cấu hình trong playwright.config.ts mà không cần truyền thủ công trong từng test.
Khai báo type APIClient trong generic của base.extend<{ apiClient: APIClient }> cho IDE biết kiểu trả về khi test destructure { apiClient }.
Trong playwright.config.ts, đảm bảo baseURL được khai báo:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
},
});
Test Usage
Import test và expect từ fixtures.ts thay vì từ @playwright/test:
// user.spec.ts
import { test, expect } from './fixtures';
test('user có thể được tạo và xóa', async ({ apiClient }) => {
const user = await apiClient.createUser({
email: '[email protected]',
name: 'Test',
});
expect(user.id).toBeDefined();
await apiClient.deleteUser(user.id);
});
test('danh sách user không rỗng', async ({ apiClient }) => {
const users = await apiClient.listUsers();
expect(Array.isArray(users)).toBe(true);
});
Test chỉ biết createUser, deleteUser, listUsers — không biết gì về HTTP verb, path, hay response parsing. Nếu API đổi endpoint từ /users sang /api/v2/users, chỉ cần sửa trong APIClient class ở một chỗ.
4 Lợi Ích Cốt Lõi
DRY — Tái sử dụng call logic
HTTP path, method, body shape, response parsing đều nằm trong class. Mỗi test gọi một method, không lặp lại implementation detail. Khi API schema thay đổi, sửa một file duy nhất.
Type-safe với autocomplete
createUser nhận { email: string; name: string } — TypeScript báo lỗi compile-time nếu test truyền sai field:
// TypeScript báo lỗi: Property 'email' is missing
await apiClient.createUser({ name: 'Test' });
// IDE autocomplete hiện ra: email, name
await apiClient.createUser({ email: '...', name: '...' });
Return type cũng được infer từ method — user.id, user.email có autocomplete nếu khai báo return type explicit trong class.
Centralized error handling
Mỗi method kiểm tra res.ok() và throw kèm status code. Test không cần tự xử lý non-2xx — lỗi propagate tự nhiên như Exception trong test framework:
async createUser(data: { email: string; name: string }) {
const res = await this.request.post(`${this.baseURL}/users`, { data });
if (!res.ok()) throw new Error(`Create user failed: ${res.status()}`);
return await res.json();
}
// Test fail với message rõ ràng: "Create user failed: 422"
// thay vì TypeError khi cố gọi .id trên undefined
Mock-able
Fixture inject class qua use(client) — có thể thay class thật bằng class giả mà không sửa test:
// mock-api-client.ts — cho test offline
export class MockAPIClient implements APIClient {
async createUser(data) {
return { id: 'mock-id-123', ...data };
}
async deleteUser(id) { /* no-op */ }
async listUsers() { return []; }
}
Pattern Extend Với Auth
Khi API yêu cầu Bearer token, tạo subclass thay vì thêm auth logic vào APIClient gốc:
// api-client.ts (thêm vào file hoặc tách file riêng)
export class AuthedAPIClient extends APIClient {
constructor(
request: APIRequestContext,
baseURL: string,
private token: string,
) {
super(request, baseURL);
}
async createUser(data: { email: string; name: string }) {
const res = await this.request.post(`${this.baseURL}/users`, {
data,
headers: { Authorization: `Bearer ${this.token}` },
});
if (!res.ok()) throw new Error(`Create user failed: ${res.status()}`);
return await res.json();
}
}
Fixture cho AuthedAPIClient nhận token từ environment variable hoặc fixture khác (ví dụ authToken fixture):
// fixtures.ts — thêm fixture authedApiClient
export const test = base.extend<{
apiClient: APIClient;
authedApiClient: AuthedAPIClient;
}>({
apiClient: async ({ request, baseURL }, use) => {
await use(new APIClient(request, baseURL!));
},
authedApiClient: async ({ request, baseURL }, use) => {
const token = process.env.API_TOKEN;
if (!token) throw new Error('API_TOKEN env var is required');
await use(new AuthedAPIClient(request, baseURL!, token));
},
});
Khi token có expiry ngắn, lấy token động từ API login trong setup fixture thay vì đọc từ env:
authedApiClient: async ({ request, baseURL }, use) => {
// Lấy token mới cho mỗi test — tránh expired token issue
const loginRes = await request.post(`${baseURL}/auth/token`, {
data: {
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
},
});
if (!loginRes.ok()) throw new Error('Auth token fetch failed');
const { token } = await loginRes.json();
await use(new AuthedAPIClient(request, baseURL!, token));
},
Combine Với testUser Fixture — Dependency Chain
testUser fixture phụ thuộc vào apiClient fixture — đây là dependency chain: Playwright tự giải quyết thứ tự khởi tạo.
// fixtures.ts — thêm testUser
type Fixtures = {
apiClient: APIClient;
testUser: { id: string; email: string; name: string };
};
export const test = base.extend<Fixtures>({
apiClient: async ({ request, baseURL }, use) => {
await use(new APIClient(request, baseURL!));
},
testUser: async ({ apiClient }, use) => {
// Setup: tạo user trước test
const user = await apiClient.createUser({
email: `test-${Date.now()}@x.com`,
name: 'Test User',
});
// Truyền user xuống test body
await use(user);
// Teardown: xóa user sau test — chạy kể cả khi test fail
await apiClient.deleteUser(user.id);
},
});
Test dùng testUser fixture không cần tự create hay delete user:
test('profile page hiển thị đúng', async ({ page, testUser }) => {
await page.goto(`/users/${testUser.id}`);
await expect(page.getByText(testUser.name)).toBeVisible();
await expect(page.getByText(testUser.email)).toBeVisible();
});
test('update user name', async ({ apiClient, testUser }) => {
// testUser đã được tạo sẵn — test chỉ cần test logic update
const updated = await apiClient.updateUser(testUser.id, { name: 'Updated' });
expect(updated.name).toBe('Updated');
// Teardown tự động — testUser fixture xóa user sau test
});
Cơ chế: khi testUser fixture được request, Playwright khởi tạo apiClient trước (nó là dependency), sau đó khởi tạo testUser dùng apiClient. Teardown chạy ngược: teardown testUser trước (xóa user), rồi teardown apiClient (cleanup request context). Thứ tự này được Playwright đảm bảo tự động.
Pattern testEnvironment Helper
Khi test cần nhiều loại data phức tạp (nhiều user, nhiều order, ...), tạo testEnv fixture cung cấp helper methods:
type TestEnv = {
seedUsers: (count: number) => Promise<Array<{ id: string; email: string; name: string }>>;
};
export const test = base.extend<{
apiClient: APIClient;
testEnv: TestEnv;
}>({
apiClient: async ({ request, baseURL }, use) => {
await use(new APIClient(request, baseURL!));
},
testEnv: async ({ apiClient }, use) => {
const created: string[] = []; // track IDs để cleanup
const env: TestEnv = {
async seedUsers(count: number) {
const users = await Promise.all(
Array(count).fill(0).map((_, i) =>
apiClient.createUser({
email: `seed-${i}-${Date.now()}@x.com`,
name: `Seed User ${i}`,
})
)
);
created.push(...users.map((u) => u.id));
return users;
},
};
await use(env);
// Cleanup tất cả user đã seed
await Promise.all(created.map((id) => apiClient.deleteUser(id)));
},
});
Test dùng testEnv để chuẩn bị data phức tạp:
test('bảng user hiển thị pagination đúng', async ({ page, testEnv }) => {
// Seed 25 user để test pagination (mỗi trang 10 item)
await testEnv.seedUsers(25);
await page.goto('/admin/users');
await expect(page.getByRole('row')).toHaveCount(11); // 10 row + 1 header
await page.getByLabel('Next page').click();
await expect(page.getByRole('row')).toHaveCount(11);
// Cleanup 25 user được xử lý tự động sau test
});
Pattern này hữu ích hơn nhiều testUser đơn lẻ khi test feature yêu cầu volume data nhất định (pagination, sorting, search indexing).
Best Practices Phân Tách Domain
Khi project có nhiều domain (user, order, product, ...), không nhồi tất cả vào một class:
// user-api.ts
export class UserAPI {
constructor(
private request: APIRequestContext,
private baseURL: string,
) {}
async create(data: { email: string; name: string }) { /* ... */ }
async delete(id: string) { /* ... */ }
async list() { /* ... */ }
async getById(id: string) { /* ... */ }
}
// order-api.ts
export class OrderAPI {
constructor(
private request: APIRequestContext,
private baseURL: string,
) {}
async create(data: { userId: string; productId: string; qty: number }) { /* ... */ }
async cancel(id: string) { /* ... */ }
async listByUser(userId: string) { /* ... */ }
}
// product-api.ts
export class ProductAPI {
constructor(
private request: APIRequestContext,
private baseURL: string,
) {}
async create(data: { name: string; price: number }) { /* ... */ }
async delete(id: string) { /* ... */ }
}
Fixture aggregate nhiều domain client:
type APIClients = {
users: UserAPI;
orders: OrderAPI;
products: ProductAPI;
};
export const test = base.extend<{ api: APIClients }>({
api: async ({ request, baseURL }, use) => {
const base = baseURL!;
await use({
users: new UserAPI(request, base),
orders: new OrderAPI(request, base),
products: new ProductAPI(request, base),
});
},
});
Test dùng api.users.create(), api.orders.cancel() — rõ ràng về domain, IDE autocomplete theo từng class.
Quy tắc đặt tên method:
- Dùng
createUser(hoặcusers.create), không dùngpostUsers— tên nên phản ánh business action, không phải HTTP verb. - Method đọc:
getById,list,search. - Method ghi:
create,update,delete,archive.
So Sánh Với request Fixture Low-level
| Khía cạnh | request fixture (low-level) |
API Client fixture (pattern này) |
|---|---|---|
| Abstraction | HTTP transport thuần — verb, path, options | Domain method — createUser, deleteOrder |
| Type safety | data là object bất kỳ |
Method signature có type cụ thể |
| Error handling | Test tự quyết định — check res.ok() hay không |
Tập trung trong class — throw on non-2xx mặc định |
| Code duplication | Path, body shape lặp ở mỗi test | Một lần trong class, test gọi method |
| Mock | Cần mock từng request.post call |
Thay cả class — một điểm swap |
| Raw response access | Trực tiếp — res.headers(), res.status() |
Class ẩn response — cần expose thêm nếu cần header |
| Phù hợp khi | Test API contract một-off, cần raw response detail | Nhiều test cùng gọi một endpoint, project scale lớn |
Hai cách không loại trừ nhau. Trong cùng một project, request fixture dùng cho test API contract chi tiết (verify header, status code cụ thể), còn API Client fixture dùng cho setup/teardown và business logic test.
Mock Pattern
Để test offline hoặc isolate test khỏi backend state, inject mock implementation thay vì real APIClient. TypeScript interface giúp đảm bảo mock implement đúng contract:
// api-client.interface.ts
export interface IAPIClient {
createUser(data: { email: string; name: string }): Promise<{ id: string; email: string; name: string }>;
deleteUser(id: string): Promise<void>;
listUsers(): Promise<Array<{ id: string; email: string; name: string }>>;
}
// api-client.ts
export class APIClient implements IAPIClient {
// ... implementation thật
}
// mock-api-client.ts
export class MockAPIClient implements IAPIClient {
private users: Array<{ id: string; email: string; name: string }> = [];
async createUser(data: { email: string; name: string }) {
const user = { id: `mock-${Date.now()}`, ...data };
this.users.push(user);
return user;
}
async deleteUser(id: string) {
this.users = this.users.filter((u) => u.id !== id);
}
async listUsers() {
return [...this.users];
}
}
Fixture mock — override fixture apiClient cho test offline:
// fixtures.offline.ts
import { test as base } from './fixtures'; // import test đã có apiClient
import { MockAPIClient } from './mock-api-client';
export const test = base.extend<{}>({
// Override apiClient bằng mock implementation
apiClient: async ({}, use) => {
await use(new MockAPIClient() as any);
},
});
Test offline import từ fixtures.offline.ts thay vì fixtures.ts — không cần sửa test code, chỉ đổi import.
Lưu ý: mock pattern này phù hợp cho test logic phụ thuộc vào data structure, không phù hợp cho test verify API integration thật. Tách rõ hai loại test file.
Limitations
Over-engineering cho project nhỏ
Nếu project chỉ có 5-10 test và 2-3 endpoint, viết class + fixture + interface + mock là overhead không tương xứng. request fixture dùng trực tiếp trong test là đủ. Pattern này có giá trị rõ ràng từ khoảng 20+ test hoặc khi cùng một endpoint được gọi từ 5+ chỗ.
Maintain type khi API schema thay đổi
Khi backend thêm field bắt buộc vào createUser (ví dụ thêm role: string), cần cập nhật:
- Method signature trong
APIClientclass. - Interface
IAPIClientnếu có. - Mọi test gọi
apiClient.createUser(...)— TypeScript báo lỗi compile tại đây. - Mock class.
TypeScript error sẽ chỉ rõ nơi cần sửa, nhưng volume sửa vẫn nhiều hơn so với project không dùng pattern này (ở đó chỉ thêm field vào data object).
Test API contract cần raw response
Class ẩn APIResponse object — test không thể verify Location header, Set-Cookie, hay custom header của response. Cho test API contract cần verify detail như vậy, dùng request fixture trực tiếp.
4 Pitfalls
Pitfall 1: Class state mutable gây test isolation issue
Nếu APIClient lưu trạng thái trong property (ví dụ cache user list), các test dùng chung worker-scoped fixture sẽ thấy state từ test trước:
// SAI — class lưu state
class APIClient {
private cachedUsers: any[] | null = null; // <-- state
async listUsers() {
if (this.cachedUsers) return this.cachedUsers; // test thứ 2 thấy cache cũ
const res = await this.request.get(`${this.baseURL}/users`);
this.cachedUsers = await res.json();
return this.cachedUsers;
}
}
Giữ class stateless — không lưu data từ response vào property. Nếu cần cache, scope nó trong test body (local variable), không trong class.
Pitfall 2: Quên check res.ok() trong một số method
Khi thêm method mới, dễ bỏ quên if (!res.ok()) throw. Method trả về kết quả của response lỗi dưới dạng "data hợp lệ":
// SAI — không check ok()
async getUser(id: string) {
const res = await this.request.get(`${this.baseURL}/users/${id}`);
return await res.json(); // Nếu 404, trả về { message: 'Not found' } — không phải user object
}
// Test vẫn pass vì object có field, nhưng không phải user thật
const user = await apiClient.getUser('nonexistent');
console.log(user.id); // undefined — bug bị che khuất
// ĐÚNG
async getUser(id: string) {
const res = await this.request.get(`${this.baseURL}/users/${id}`);
if (!res.ok()) throw new Error(`Get user failed: ${res.status()}`);
return await res.json();
}
Pitfall 3: Hardcode baseURL trong class
// SAI — không reconfigurable
class APIClient {
async createUser(data) {
const res = await this.request.post('http://localhost:3000/users', { data });
// ...
}
}
// Khi chạy trên CI (port khác) hoặc staging — fail
Luôn nhận baseURL từ constructor, và fixture đọc baseURL từ Playwright config ({ request, baseURL }) thay vì hardcode.
Pitfall 4: Token hardcode trong constructor gây expire issue
// SAI — token hardcode hoặc đọc từ env một lần
class AuthedAPIClient extends APIClient {
constructor(request, baseURL) {
super(request, baseURL);
this.token = 'hardcoded-token-abc'; // hoặc process.env.TOKEN
}
}
// Token expire sau 1 giờ → test fail lúc chạy CI ban đêm
Lấy token động trong fixture setup (gọi login endpoint mỗi test run) thay vì hardcode. Nếu token có TTL dài, lấy một lần trong worker-scoped fixture để không login lại quá nhiều lần.
Quiz + Bài Tiếp
Quiz
Câu 1
Fixture apiClient nhận { request, baseURL } làm dependency. Điều này có nghĩa là gì khi chạy test?
- Playwright tạo
APIClientmột lần dùng chung toàn bộ test suite. - Playwright inject
requestvàbaseURLbuilt-in fixtures vào fixture function trước khi tạoAPIClient, đảm bảobaseURLtừ config được dùng. - Test phải tự truyền
baseURLkhi gọi method. - Fixture yêu cầu
baseURLđược khai báo trực tiếp trong fixture file, không đọc từ config.
Đáp án
B. Playwright dependency injection tự động resolve built-in fixtures request và baseURL trước khi chạy fixture function apiClient. baseURL có giá trị từ playwright.config.ts — fixture không cần biết config ở đâu. A sai — scope mặc định là test-level (mỗi test một instance). C sai — test chỉ destructure { apiClient }. D sai — đọc từ config thông qua built-in fixture baseURL.
Câu 2
Method createUser trong APIClient throw Error khi !res.ok(). Khi test gọi apiClient.createUser(data) và server trả 422, điều gì xảy ra?
- Test nhận object
{ message: 'Validation failed' }và tiếp tục chạy. - Test fail với uncaught Error, message "Create user failed: 422". Playwright ghi lỗi vào report.
apiClientfixture tự retry request tối đa 3 lần.- Test bị skip, không fail.
Đáp án
B. Error throw từ fixture method propagate lên test body như exception thông thường. Nếu test không catch, Playwright mark test là FAILED với stack trace và message. Đây là mục tiêu của centralized error handling — lỗi bắn lên ngay với thông tin rõ ràng, không phải undefined field sau đó. A sai vì throw ngắt flow. C sai — không có auto-retry ở đây. D sai.
Câu 3
Fixture testUser depend vào apiClient. Playwright xử lý teardown theo thứ tự nào?
- Teardown
apiClienttrước, rồitestUser. - Teardown
testUsertrước (xóa user), rồiapiClient. - Teardown song song — không có thứ tự xác định.
- Chỉ teardown fixture được test destructure trực tiếp.
Đáp án
B. Playwright teardown theo thứ tự ngược setup: fixture được setup sau thì teardown trước. Vì testUser setup sau apiClient (nó depend vào apiClient), nên testUser teardown trước — đảm bảo user được xóa trong khi apiClient vẫn còn hoạt động. Nếu teardown apiClient trước thì testUser teardown sẽ không có client để gọi delete. A sai vì thứ tự ngược. C sai — Playwright đảm bảo thứ tự. D sai — dependency transitive đều được teardown.
Câu 4
Tại sao class APIClient nên stateless (không lưu data từ response vào property)?
- TypeScript không hỗ trợ property trong class dùng trong fixture.
- Class stateless giúp test isolation — mỗi lần gọi method nhận fresh data từ server, không bị ảnh hưởng bởi state từ call trước (cùng test hoặc test khác nếu worker-scoped).
- Class có state sẽ không compile được với TypeScript strict mode.
- Playwright fixture không hỗ trợ inject object có property.
Đáp án
B. Test isolation là lý do cốt lõi. Nếu class cache data, test A modify data, test B (chạy sau trong cùng worker) có thể nhận cache stale. Với worker-scoped fixture, vấn đề này nghiêm trọng hơn vì nhiều test share cùng instance. A, C, D đều sai — TypeScript và Playwright không có hạn chế này.
Câu 5
Khi nào không nên dùng API Client Fixture pattern?
- Khi project có hơn 50 test file.
- Khi cần verify
Locationheader trong response củaPOST /users. - Khi API yêu cầu Bearer token.
- Khi cần seed nhiều loại data trước test.
Đáp án
B. API Client fixture ẩn APIResponse object — test không truy cập được header của response. Để verify Location, Set-Cookie, hay custom header, dùng request fixture trực tiếp và giữ reference tới response object. A sai — với 50+ test là lúc pattern này phát huy tốt. C sai — có AuthedAPIClient pattern cho trường hợp này. D sai — testEnvironment helper fixture giải quyết trường hợp này.
Bài Tiếp Theo
Bài 30: Pattern — DB Seeder Fixture — pattern tạo fixture seed database trực tiếp qua connection (không qua HTTP API), dùng cho test cần data volume lớn hoặc data phức tạp hơn những gì public API cho phép tạo.
