API Testing với Playwright & TypeScript
Hướng dẫn thực chiến cho Testers — Playwright v1.x · TypeScript 5.x · Node.js 20+
| Đối tượng | Testers muốn học Automation Testing |
| Thời lượng đọc | ~45 phút |
| Cấp độ | Intermediate |
1. Tại sao dùng Playwright cho API Testing?
Playwright thường được biết đến như một công cụ E2E testing cho browser, nhưng ít ai biết rằng nó còn là một API testing tool cực kỳ mạnh mẽ. Khác với Postman hay REST Assured, Playwright cho phép bạn kết hợp liền mạch giữa API calls và UI interactions trong cùng một test suite.
1.1. Ưu Điểm Nổi Bật
- Tích hợp sẵn HTTP client (
request) — không cần cài thêm axios/fetch - TypeScript first — type-safe từ request đến response
- Chạy song song (parallel) mặc định — test nhanh hơn nhiều
- Kết hợp API + UI trong cùng 1 test dễ dàng (setup data qua API, verify trên UI)
- Built-in retry, timeout, và rich assertions
- HTML Report đẹp, tích hợp CI/CD dễ dàng
Khi project đã dùng Playwright cho E2E testing — giúp giảm số lượng tool, dùng chung fixtures/helpers, và team không cần học thêm công cụ mới.
1.2. So Sánh Với Các Tool Phổ Biến
| Tiêu chí | Playwright | Postman | REST Assured |
|---|---|---|---|
| Ngôn ngữ | TypeScript/JS | GUI/JS | Java |
| Tích hợp CI/CD | Dễ dàng | Vừa phải | Khá dễ |
| Kết h ợp UI + API | ✅ Xuất sắc | ❌ Không thể | ❌ Không thể |
| Learning curve | Thấp | Rất thấp | Cao |
| Parallel testing | ✅ Mặc định | 💰 Trả phí | Manual |
2. Cài Đặt & Cấu Hình Project
2.1. Khởi Tạo Project
Node.js 18+ đã được cài đặt trên máy.
# Khởi tạo với Playwright wizard (chọn TypeScript khi được hỏi)
npm init playwright@latest
# Hoặc cài thủ công
npm init -y
npm install -D @playwright/test typescript @types/node dotenv
npx playwright install --with-deps chromium
2.2. Cấu Trúc Thư Mục Đề Xuất
playwright-api-testing/
├── tests/
│ ├── api/
│ │ ├── auth.spec.ts # Tests xác thực
│ │ ├── users.spec.ts # CRUD user tests
│ │ └── products.spec.ts # CRUD product tests
│ └── auth.setup.ts # Setup — login & lưu token
├── fixtures/
│ ├── api-fixture.ts # Custom fixtures
│ └── test-data.ts # Factory tạo test data
├── helpers/
│ ├── api-client.ts # Wrapper cho API calls
│ └── auth-helper.ts # Quản lý token
├── .auth/ # Token cache (gitignore!)
├── playwright.config.ts
├── .env # Biến môi trường (gitignore!)
└── package.json
2.3. playwright.config.ts
import { defineConfig } from '@playwright/test';
import * as dotenv from 'dotenv';
dotenv.config();
export default defineConfig({
testDir: './tests',
timeout: 30_000,
retries: process.env.CI ? 2 : 0, // Retry 2 lần khi chạy CI
workers: process.env.CI ? 4 : 2, // Parallel workers
reporter: [['html'], ['list']],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
extraHTTPHeaders: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
},
projects: [
// Bước 1: Setup — chạy trước tất cả
{ name: 'setup', testMatch: '**/auth.setup.ts' },
// Bước 2: Tests thực sự — dùng state đã có
{
name: 'api-tests',
testMatch: 'tests/api/**/*.spec.ts',
dependencies: ['setup'],
use: { storageState: '.auth/user.json' },
},
],
});
3. REST API Testing — GET / POST / PUT / DELETE
Playwright cung cấp request.get(), request.post(), request.put(), request.delete() với API đơn giản, type-safe, có full assertions support.
3.1. GET — Lấy Dữ Liệu
import { test, expect } from '@playwright/test';
test.describe('Users API — GET', () => {
test('GET /users — trả về danh sách users', async ({ request }) => {
const response = await request.get('/api/users');
expect(response.status()).toBe(200);
const body = await response.json();
expect(Array.isArray(body.data)).toBeTruthy();
expect(body.data.length).toBeGreaterThan(0);
expect(body.total).toBeDefined();
});
test('GET /users/:id — trả về user theo id', async ({ request }) => {
const response = await request.get('/api/users/1');
expect(response.status()).toBe(200);
const user = await response.json();
expect(user).toMatchObject({
id: 1,
email: expect.stringContaining('@'),
name: expect.any(String),
});
});
test('GET /users/:id — 404 khi user không tồn tại', async ({ request }) => {
const response = await request.get('/api/users/99999');
expect(response.status()).toBe(404);
});
test('GET /users — hỗ trợ query params phân trang', async ({ request }) => {
const response = await request.get('/api/users', {
params: { page: 1, limit: 5, sort: 'name' },
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.data.length).toBeLessThanOrEqual(5);
});
});
3.2. POST — Tạo Mới
test.describe('Users API — POST', () => {
test('POST /users — tạo user mới thành công', async ({ request }) => {
const newUser = {
name: 'Nguyen Van A',
email: `user_${Date.now()}@test.com`, // Email unique
role: 'student',
};
const response = await request.post('/api/users', { data: newUser });
expect(response.status()).toBe(201);
const created = await response.json();
expect(created.id).toBeDefined();
expect(created.email).toBe(newUser.email);
expect(created.name).toBe(newUser.name);
});
test('POST /users — lỗi 400 khi thiếu email', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Test User' }, // Thiếu email
});
expect(response.status()).toBe(400);
const error = await response.json();
expect(error.message).toContain('email');
});
test('POST /users — lỗi 409 khi email đã tồn tại', async ({ request }) => {
const email = `dup_${Date.now()}@test.com`;
await request.post('/api/users', { data: { name: 'User 1', email } });
const response = await request.post('/api/users', { data: { name: 'User 2', email } });
expect(response.status()).toBe(409);
});
});
3.3. PUT / PATCH — Cập Nhật
test.describe('Users API — PUT/PATCH', () => {
let userId: number;
// Tạo user mới trước mỗi test
test.beforeEach(async ({ request }) => {
const res = await request.post('/api/users', {
data: { name: 'To Be Updated', email: `upd_${Date.now()}@test.com` },
});
userId = (await res.json()).id;
});
test('PUT /users/:id — cập nhật toàn bộ user', async ({ request }) => {
const response = await request.put(`/api/users/${userId}`, {
data: {
name: 'Nguyen Van B Updated',
email: `new_${Date.now()}@test.com`,
role: 'admin',
},
});
expect(response.status()).toBe(200);
expect((await response.json()).name).toBe('Nguyen Van B Updated');
});
test('PATCH /users/:id — chỉ cập nhật một phần', async ({ request }) => {
const response = await request.patch(`/api/users/${userId}`, {
data: { name: 'Partial Update Only' },
});
expect(response.status()).toBe(200);
const updated = await response.json();
expect(updated.name).toBe('Partial Update Only');
expect(updated.email).toBeDefined(); // Email không thay đổi
});
});
3.4. DELETE — Xóa
test.describe('Users API — DELETE', () => {
test('DELETE /users/:id — xóa thành công', async ({ request }) => {
// Arrange: Tạo user để xóa
const { id } = await request.post('/api/users', {
data: { name: 'To Delete', email: `del_${Date.now()}@test.com` },
}).then(r => r.json());
// Act: Xóa
const deleteRes = await request.delete(`/api/users/${id}`);
expect(deleteRes.status()).toBe(204); // No Content
// Assert: Xác nhận đã bị xóa
const getRes = await request.get(`/api/users/${id}`);
expect(getRes.status()).toBe(404);
});
test('DELETE /users/:id — 404 khi xóa user không tồn tại', async ({ request }) => {
const response = await request.delete('/api/users/99999');
expect(response.status()).toBe(404);
});
});
Mỗi API test nên theo pattern:
- Arrange — chuẩn bị dữ liệu
- Act — gọi API
- Assert — kiểm tra kết quả
Pattern kinh điển giúp test dễ đọc và dễ maintain.
4. Authentication — JWT & Bearer Token
Đây là phần cốt lõi của API testing thực tế. Hầu hết các enterprise API đều yêu cầu xác thực. Playwright cung cấp nhiều cách xử lý authentication một cách elegant và scalable.
4.1. AuthHelper — Wrapper Xác Thực
import { APIRequestContext } from '@playwright/test';
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export class AuthHelper {
constructor(private request: APIRequestContext) {}
async login(email: string, password: string): Promise<AuthTokens> {
const response = await this.request.post('/api/auth/login', {
data: { email, password },
});
if (!response.ok()) {
throw new Error(`Login failed: ${response.status()}`);
}
return response.json();
}
async getAdminToken(): Promise<string> {
const { accessToken } = await this.login(
process.env.ADMIN_EMAIL!,
process.env.ADMIN_PASSWORD!
);
return accessToken;
}
getBearerHeader(token: string) {
return { Authorization: `Bearer ${token}` };
}
}
4.2. Test Login / Register / Me
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('POST /auth/register — đăng ký tài khoản mới', async ({ request }) => {
const response = await request.post('/api/auth/register', {
data: {
name: 'New Student',
email: `student_${Date.now()}@softech.edu.vn`,
password: 'Softech@2024',
},
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.accessToken).toBeDefined();
expect(body.user.email).toBeDefined();
// KHÔNG BAO GIỜ trả về password trong response
expect(body.user.password).toBeUndefined();
});
test('POST /auth/login — đăng nhập thành công', async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
});
expect(response.status()).toBe(200);
const tokens = await response.json();
expect(tokens.accessToken).toBeDefined();
expect(tokens.refreshToken).toBeDefined();
expect(typeof tokens.expiresIn).toBe('number');
});
test('POST /auth/login — 401 khi sai mật khẩu', async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'wrongpassword123' },
});
expect(response.status()).toBe(401);
});
test('GET /auth/me — lấy thông tin user hiện tại', async ({ request }) => {
// Bước 1: Đăng nhập lấy token
const loginRes = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
});
const { accessToken } = await loginRes.json();
// Bước 2: Gọi protected endpoint với token
const meRes = await request.get('/api/auth/me', {
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(meRes.status()).toBe(200);
const me = await meRes.json();
expect(me.email).toBe(process.env.TEST_USER_EMAIL);
});
test('GET /auth/me — 401 khi không có token', async ({ request }) => {
const response = await request.get('/api/auth/me');
expect(response.status()).toBe(401);
});
});
4.3. Token Caching Với storageState
Thay vì login trong mỗi test, dùng storageState để cache token một lần và tái s ử dụng — giúp test suite chạy nhanh hơn đáng kể.
import { test as setup } from '@playwright/test';
import path from 'path';
import fs from 'fs';
const AUTH_FILE = '.auth/user.json';
// File này chạy 1 LẦN trước tất cả tests
setup('authenticate as user', async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
});
const { accessToken } = await response.json();
// Lưu token vào file — các test sẽ đọc từ đây
fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true });
fs.writeFileSync(AUTH_FILE, JSON.stringify({
origins: [{
origin: process.env.BASE_URL,
localStorage: [{ name: 'accessToken', value: accessToken }],
}],
}));
});
// Helper đọc token từ file cache
export function getStoredToken(): string {
const auth = JSON.parse(fs.readFileSync('.auth/user.json', 'utf-8'));
return auth.origins[0].localStorage
.find((item: any) => item.name === 'accessToken')?.value ?? '';
}
4.4. Test Role-Based Access Control (RBAC)
test.describe('Protected Endpoints — RBAC', () => {
test('USER không thể truy cập ADMIN endpoint', async ({ request }) => {
// User thường → Forbidden
const userRes = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${process.env.USER_TOKEN}` },
});
expect(userRes.status()).toBe(403);
// Admin → OK
const adminRes = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${process.env.ADMIN_TOKEN}` },
});
expect(adminRes.status()).toBe(200);
});
test('Token hết hạn — Refresh token', async ({ request }) => {
const response = await request.post('/api/auth/refresh', {
data: { refreshToken: process.env.REFRESH_TOKEN },
});
expect(response.status()).toBe(200);
const { accessToken } = await response.json();
expect(accessToken).toBeDefined();
// Access token mới phải khác refresh token
expect(accessToken).not.toBe(process.env.REFRESH_TOKEN);
});
test('Token không hợp lệ — 401', async ({ request }) => {
const response = await request.get('/api/profile', {
headers: { Authorization: 'Bearer invalid.token.here' },
});
expect(response.status()).toBe(401);
});
});
5. Fixtures & Data-Driven Testing
Fixtures là tính năng mạnh nhất của Playwright Testing. Giúp tổ chức code tốt hơn, tránh lặp code, setup/teardown tự động, và tạo test data chuyên nghiệp.
5.1. Custom Fixtures Với Auto Cleanup
import { test as base, APIRequestContext } from '@playwright/test';
type ApiFixtures = {
adminRequest: APIRequestContext; // Request với admin token
userRequest: APIRequestContext; // Request với user token
testUser: { id: number; email: string; token: string };
};
export const test = base.extend<ApiFixtures>({
// Fixture: request đã có admin token
adminRequest: async ({ playwright }, use) => {
const ctx = await playwright.request.newContext({
baseURL: process.env.BASE_URL,
extraHTTPHeaders: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.ADMIN_TOKEN}`,
},
});
await use(ctx);
await ctx.dispose(); // Tự động cleanup
},
// Fixture: tạo user mới, trả về, tự động xóa sau test
testUser: async ({ request }, use) => {
const email = `test_${Date.now()}@softech.edu.vn`;
// SETUP: Tạo user
const createRes = await request.post('/api/users', {
data: { name: 'Test User', email, password: 'Test@123' },
});
const user = await createRes.json();
// Đăng nhập lấy token
const loginRes = await request.post('/api/auth/login', {
data: { email, password: 'Test@123' },
});
const { accessToken } = await loginRes.json();
// Đưa vào test
await use({ id: user.id, email, token: accessToken });
// TEARDOWN: Tự động xóa user sau khi test xong
await request.delete(`/api/users/${user.id}`, {
headers: { Authorization: `Bearer ${process.env.ADMIN_TOKEN}` },
});
},
});
export { expect } from '@playwright/test';
5.2. Sử Dụng Fixtures
// Import từ fixture của mình thay vì @playwright/test
import { test, expect } from '../../fixtures/api-fixture';
test('test với testUser fixture', async ({ testUser, request }) => {
// testUser đã có sẵn id và token
const response = await request.get('/api/profile', {
headers: { Authorization: `Bearer ${testUser.token}` },
});
expect(response.status()).toBe(200);
// User sẽ tự động bị xóa sau test này
});
test('admin xem được tất cả users', async ({ adminRequest }) => {
const response = await adminRequest.get('/api/admin/users');
expect(response.status()).toBe(200);
const body = await response.json();
expect(Array.isArray(body.data)).toBeTruthy();
});
5.3. Data-Driven Testing
Chạy cùng một logic với nhiều bộ dữ liệu — hiệu quả nhất để kiểm tra validation và edge cases.
import { test, expect } from '@playwright/test';
// Định nghĩa các invalid test cases
const INVALID_REGISTER_CASES = [
{
desc: 'thiếu email',
body: { name: 'Test', password: 'Valid@123' },
expectedStatus: 400,
expectedError: 'email',
},
{
desc: 'email sai định dạng',
body: { name: 'Test', email: 'not-an-email', password: 'Valid@123' },
expectedStatus: 400,
expectedError: 'email',
},
{
desc: 'password quá ngắn (< 8 ký tự)',
body: { name: 'Test', email: 'ok@ok.com', password: '123' },
expectedStatus: 400,
expectedError: 'password',
},
{
desc: 'thiếu name',
body: { email: 'ok@ok.com', password: 'Valid@123' },
expectedStatus: 400,
expectedError: 'name',
},
];
test.describe('POST /auth/register — Validation', () => {
for (const tc of INVALID_REGISTER_CASES) {
test(`trả về 400 khi ${tc.desc}`, async ({ request }) => {
const response = await request.post('/api/auth/register', {
data: tc.body,
});
expect(response.status()).toBe(tc.expectedStatus);
const body = await response.json();
// Kiểm tra error message chứa từ khóa
expect(JSON.stringify(body).toLowerCase()).toContain(tc.expectedError);
});
}
});
// Test nhiều roles khác nhau
const USER_ROLES = ['student', 'teacher', 'admin'];
for (const role of USER_ROLES) {
test(`tạo user với role '${role}' thành công`, async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: `Test ${role}`, email: `${role}_${Date.now()}@test.com`, role },
});
expect(response.status()).toBe(201);
expect((await response.json()).role).toBe(role);
});
}
5.4. Test Data Factory
let counter = 0;
// Factory tạo user data — đảm bảo unique mỗi lần
export function createUserData(overrides: Partial<{
name: string;
email: string;
role: 'student' | 'teacher' | 'admin';
password: string;
}> = {}) {
counter++;
return {
name: `Test User ${counter}`,
email: `user_${counter}_${Date.now()}@test.softech.edu.vn`,
role: 'student' as const,
password: 'Softech@2024',
...overrides, // Override bất kỳ field nào
};
}
export function createProductData(overrides = {}) {
counter++;
return {
name: `Product ${counter}`,
price: 99000 + counter * 1000,
category: 'education',
inStock: true,
...overrides,
};
}
import { createUserData } from '../../fixtures/test-data';
test('tạo nhiều users song song', async ({ request }) => {
const usersData = [
createUserData({ role: 'student' }),
createUserData({ role: 'teacher' }),
createUserData({ role: 'admin' }),
];
// Promise.all chạy song song — nhanh hơn sequential
const responses = await Promise.all(
usersData.map(data => request.post('/api/users', { data }))
);
for (const res of responses) {
expect(res.status()).toBe(201);
}
});
6. Best Practices & Patterns Nâng Cao
6.1. API Client Wrapper — Tái Sử Dụng Code
import { APIRequestContext, expect } from '@playwright/test';
export class ApiClient {
constructor(
private request: APIRequestContext,
private token?: string
) {}
private get authHeaders() {
return this.token ? { Authorization: `Bearer ${this.token}` } : {};
}
async get<T>(path: string): Promise<T> {
const res = await this.request.get(path, { headers: this.authHeaders });
expect(res.ok(), `GET ${path} failed with ${res.status()}`).toBeTruthy();
return res.json();
}
async post<T>(path: string, data: unknown): Promise<{ status: number; body: T }> {
const res = await this.request.post(path, {
headers: this.authHeaders,
data,
});
return { status: res.status(), body: await res.json() };
}
async delete(path: string): Promise<number> {
const res = await this.request.delete(path, { headers: this.authHeaders });
return res.status();
}
}
test('clean API calls với ApiClient', async ({ request }) => {
const adminClient = new ApiClient(request, process.env.ADMIN_TOKEN);
const users = await adminClient.get<{ data: any[] }>('/api/users');
expect(users.data.length).toBeGreaterThan(0);
});
6.2. Kiểm Tra Response Headers & Schema
test('kiểm tra headers, schema và content-type', async ({ request }) => {
const response = await request.get('/api/users');
// Kiểm tra Content-Type
expect(response.headers()['content-type']).toContain('application/json');
// Kiểm tra custom headers
expect(response.headers()['x-total-count']).toBeDefined();
const body = await response.json();
// Kiểm tra shape với TypeScript
expect(body.data[0]).toMatchObject({
id: expect.any(Number),
name: expect.any(String),
email: expect.stringMatching(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
role: expect.stringMatching(/^(student|teacher|admin)$/),
});
});
6.3. Biến Môi Trường — .env
# KHÔNG commit file này lên git!
BASE_URL=http://localhost:3000
ADMIN_EMAIL=admin@softech.edu.vn
ADMIN_PASSWORD=Admin@2024
ADMIN_TOKEN=eyJhbGci... # Pre-generated admin token
TEST_USER_EMAIL=testuser@softech.edu.vn
TEST_USER_PASSWORD=Test@2024
.env
.auth/
playwright-report/
test-results/
Luôn thêm .env và .auth/ vào .gitignore. Không bao giờ hardcode credentials trong test files. Sử dụng CI/CD secrets (GitHub Actions, GitLab CI) cho production credentials.
6.4. Các Lệnh Chạy Tests
- Cơ bản
- Nâng cao
- CI/CD
# Chạy tất cả API tests
npx playwright test tests/api/
# Chạy 1 file cụ thể
npx playwright test tests/api/auth.spec.ts
# Chạy test có title chứa từ khóa
npx playwright test --grep 'login'
# Bỏ qua test chứa từ khóa
npx playwright test --grep-invert 'slow'
# Debug mode — xem từng bước
npx playwright test --debug
# Xem HTML report sau khi chạy
npx playwright show-report
# Chạy với số workers (parallel)
npx playwright test --workers=4
# Chạy với retry
npx playwright test --retries=2
# Chạy và ghi video
npx playwright test --video=on
# Chạy trong CI (headless, retry 2 lần)
CI=true npx playwright test
# Xuất report dạng JSON cho CI
npx playwright test --reporter=json > results.json
# Chạy chỉ tests thất bại lần trước
npx playwright test --last-failed
7. Tổng Kết
Sau khi hoàn thành bài viết này, bạn đã nắm được:
| Kỹ năng | |
|---|---|
| ✅ | Cấu hình project Playwright + TypeScript chuyên nghiệp |
| ✅ | Viết tests đầy đủ cho GET, POST, PUT, PATCH, DELETE |
| ✅ | Xử lý JWT Authentication và Bearer Token |
| ✅ | Tạo Custom Fixtures với setup/teardown tự động |
| ✅ | Data-Driven Testing kiểm tra nhiều scenarios |
| ✅ | API Client wrapper tái sử dụng code |
| ✅ | Best practices về biến môi trường và bảo mật |
- CI/CD: Tích hợp test suite vào GitHub Actions hoặc GitLab CI
- Contract Testing: Khám phá API contract testing với Pact.js
- Performance Testing: Thử k6 cho load testing
- Kết hợp: Dùng API tests để setup data, sau đó verify trên UI trong cùng pipeline
Softech Aptech — Automation Testing Series · Playwright · TypeScript · API Testing