关于 Playwright API Testing
Playwright API Testing 是一项使用 Playwright 编写、审查和调试 API 测试的技能,面向 QA 自动化工程师。它侧重类型安全、测试隔离、完整响应校验和可复用的 API 客户端。
主要功能
- 01使用 Playwright APIRequestContext
- 02为请求和响应定义类型
- 03独立管理测试数据生命周期
- 04校验状态码、响应体、标头和耗时
- 05按服务领域复用 API 客户端
- 06覆盖认证、分页和错误场景
适用场景
- 01为 CRUD 接口编写并验证 API 测试
- 02检查认证、权限和错误处理行为
- 03测试分页、筛选及响应标头
- 04验证 API 性能与文件上传
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ pramoddutta/ qaskills --skill playwright-api-testing安装后在 Claude Code 中输入/playwright-api-testing确认已加载。
详细介绍
Playwright API 测试技能
你是一名专注于使用 Playwright 内置 APIRequestContext 进行 API 测试的资深 QA 自动化工程师。当用户要求你使用 Playwright 编写、审查或调试 API 测试时,请遵循以下详细说明。
核心原则
- Playwright 原生 API 测试——使用
APIRequestContext,而不是外部 HTTP 库。 - 类型安全——为所有请求和响应负载定义接口。
- 隔离性——每项测试都管理自己的数据生命周期(创建、验证、清理)。
- 全面验证——检查状态码、标头、响应正文结构和耗时。
- 可复用的抽象——为每个服务域构建 API 客户端类。
项目结构
tests/
api/
auth/
auth-api.spec.ts
users/
users-api.spec.ts
users-crud.spec.ts
products/
products-api.spec.ts
fixtures/
api.fixture.ts
auth-api.fixture.ts
models/
user.model.ts
product.model.ts
api-response.model.ts
clients/
base-api-client.ts
users-api-client.ts
products-api-client.ts
utils/
api-helpers.ts
schema-validator.ts
playwright.config.ts
配置
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/api',
fullyParallel: true,
retries: process.env.CI ? 1 : 0,
reporter: [
['html'],
['json', { outputFile: 'test-results/api-results.json' }],
],
use: {
baseURL: process.env.API_BASE_URL || 'http://localhost:3000/api',
extraHTTPHeaders: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
},
});
响应模型
为所有 API 负载定义 TypeScript 接口:
// models/user.model.ts
export interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'user' | 'viewer';
createdAt: string;
updatedAt: string;
}
export interface CreateUserRequest {
email: string;
name: string;
password: string;
role?: 'admin' | 'user' | 'viewer';
}
export interface UpdateUserRequest {
name?: string;
role?: 'admin' | 'user' | 'viewer';
}
export interface UserListResponse {
data: User[];
total: number;
page: number;
pageSize: number;
}
export interface ApiError {
statusCode: number;
message: string;
error: string;
details?: Record<string, string[]>;
}
基础 API 客户端
// clients/base-api-client.ts
import { APIRequestContext, APIResponse } from '@playwright/test';
export class BaseApiClient {
protected readonly request: APIRequestContext;
protected readonly basePath: string;
constructor(request: APIRequestContext, basePath: string) {
this.request = request;
this.basePath = basePath;
}
protected async get(path: string, params?: Record<string, string>): Promise<APIResponse> {
const url = params
? `${this.basePath}${path}?${new URLSearchParams(params)}`
: `${this.basePath}${path}`;
return this.request.get(url);
}
protected async post(path: string, data: unknown): Promise<APIResponse> {
return this.request.post(`${this.basePath}${path}`, { data });
}
protected async put(path: string, data: unknown): Promise<APIResponse> {
return this.request.put(`${this.basePath}${path}`, { data });
}
protected async patch(path: string, data: unknown): Promise<APIResponse> {
return this.request.patch(`${this.basePath}${path}`, { data });
}
protected async delete(path: string): Promise<APIResponse> {
return this.request.delete(`${this.basePath}${path}`);
}
}
特定领域的 API 客户端
// clients/users-api-client.ts
import { APIRequestContext, APIResponse } from '@playwright/test';
import { BaseApiClient } from './base-api-client';
import { CreateUserRequest, UpdateUserRequest } from '../models/user.model';
export class UsersApiClient extends BaseApiClient {
constructor(request: APIRequestContext) {
super(request, '/users');
}
async list(page = 1, pageSize = 10): Promise<APIResponse> {
return this.get('', { page: String(page), pageSize: String(pageSize) });
}
async getById(id: string): Promise<APIResponse> {
return this.get(`/${id}`);
}
async create(user: CreateUserRequest): Promise<APIResponse> {
return this.post('', user);
}
async update(id: string, data: UpdateUserRequest): Promise<APIResponse> {
return this.patch(`/${id}`, data);
}
async remove(id: string): Promise<APIResponse> {
return this.delete(`/${id}`);
}
async search(query: string): Promise<APIResponse> {
return this.get('/search', { q: query });
}
}
自定义 Fixture
// fixtures/api.fixture.ts
import { test as base } from '@playwright/test';
import { UsersApiClient } from '../clients/users-api-client';
import { ProductsApiClient } from '../clients/products-api-client';
type ApiFixtures = {
usersApi: UsersApiClient;
productsApi: ProductsApiClient;
authToken: string;
};
export const test = base.extend<ApiFixtures>({
usersApi: async ({ request }, use) => {
await use(new UsersApiClient(request));
},
productsApi: async ({ request }, use) => {
await use(new ProductsApiClient(request));
},
authToken: async ({ request }, use) => {
const response = await request.post('/auth/login', {
data: {
email: '[email protected]',
password: 'AdminPass123!',
},
});
const body = await response.json();
await use(body.token);
},
});
export { expect } from '@playwright/test';
编写 API 测试
CRUD 操作
import { test, expect } from '../fixtures/api.fixture';
import { CreateUserRequest, User } from '../models/user.model';
test.describe('Users API - CRUD', () => {
let createdUserId: string;
const newUser: CreateUserRequest = {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
password: 'SecurePass123!',
role: 'user',
};
test('POST /users - should create a new user', async ({ usersApi }) => {
const response = await usersApi.create(newUser);
expect(response.status()).toBe(201);
const body: User = await response.json();
expect(body.id).toBeTruthy();
expect(body.email).toBe(newUser.email);
expect(body.name).toBe(newUser.name);
expect(body.role).toBe('user');
expect(body.createdAt).toBeTruthy();
createdUserId = body.id;
});
test('GET /users/:id - should retrieve the user', async ({ usersApi }) => {
// First create a user
const createResponse = await usersApi.create({
...newUser,
email: `get-test-${Date.now()}@example.com`,
});
const created: User = await createResponse.json();
const response = await usersApi.getById(created.id);
expect(response.status()).toBe(200);
const body: User = await response.json();
expect(body.id).toBe(created.id);
expect(body.email).toBe(created.email);
});
test('PATCH /users/:id - should update the user', async ({ usersApi }) => {
const createResponse = await usersApi.create({
...newUser,
email: `update-test-${Date.now()}@example.com`,
});
const created: User = await createResponse.json();
const response = await usersApi.update(created.id, { name: 'Updated Name' });
expect(response.status()).toBe(200);
const body: User = await response.json();
expect(body.name).toBe('Updated Name');
});
test('DELETE /users/:id - should delete the user', async ({ usersApi }) => {
const createResponse = await usersApi.create({
...newUser,
email: `delete-test-${Date.now()}@example.com`,
});
const created: User = await createResponse.json();
const deleteResponse = await usersApi.remove(created.id);
expect(deleteResponse.status()).toBe(204);
const getResponse = await usersApi.getById(created.id);
expect(getResponse.status()).toBe(404);
});
});
身份验证测试
import { test, expect } from '@playwright/test';
test.describe('Authentication API', () => {
test('should login with valid credentials', async ({ request }) => {
const response = await request.post('/auth/login', {
data: {
email: '[email protected]',
password: 'AdminPass123!',
},
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.token).toBeTruthy();
expect(body.expiresIn).toBeGreaterThan(0);
expect(body.user.email).toBe('[email protected]');
});
test('should reject invalid credentials', async ({ request }) => {
const response = await request.post('/auth/login', {
data: {
email: '[email protected]',
password: 'wrongpassword',
},
});
expect(response.status()).toBe(401);
const body = await response.json();
expect(body.message).toBe('Invalid credentials');
});
test('should access protected endpoint with token', async ({ request }) => {
// Login first
const loginResponse = await request.post('/auth/login', {
data: {
email: '[email protected]',
password: 'AdminPass123!',
},
});
const { token } = await loginResponse.json();
// Use the token
const response = await request.get('/users/me', {
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(response.status()).toBe(200);
const user = await response.json();
expect(user.email).toBe('[email protected]');
});
test('should reject expired or invalid token', async ({ request }) => {
const response = await request.get('/users/me', {
headers: {
Authorization: 'Bearer invalid.token.here',
},
});
expect(response.status()).toBe(401);
});
});
错误处理和验证
test.describe('Users API - Validation', () => {
test('should return 400 for missing required fields', async ({ request }) => {
const response = await request.post('/users', {
data: { name: 'No Email User' },
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.details).toHaveProperty('email');
});
test('should return 400 for invalid email format', async ({ request }) => {
const response = await request.post('/users', {
data: {
email: 'not-an-email',
name: 'Bad Email User',
password: 'SecurePass123!',
},
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.details.email).toContain('must be a valid email');
});
test('should return 409 for duplicate email', async ({ usersApi }) => {
const email = `duplicate-${Date.now()}@example.com`;
const userData = { email, name: 'First', password: 'Pass123!' };
await usersApi.create(userData);
const response = await usersApi.create(userData);
expect(response.status()).toBe(409);
});
test('should return 404 for non-existent resource', async ({ usersApi }) => {
const response = await usersApi.getById('non-existent-id');
expect(response.status()).toBe(404);
});
});
分页和筛选
test.describe('Users API - Pagination', () => {
test('should return paginated results', async ({ usersApi }) => {
const response = await usersApi.list(1, 5);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.data.length).toBeLessThanOrEqual(5);
expect(body.page).toBe(1);
expect(body.pageSize).toBe(5);
expect(body.total).toBeGreaterThanOrEqual(0);
});
test('should return correct page', async ({ usersApi }) => {
const page1 = await (await usersApi.list(1, 2)).json();
const page2 = await (await usersApi.list(2, 2)).json();
const page1Ids = page1.data.map((u: { id: string }) => u.id);
const page2Ids = page2.data.map((u: { id: string }) => u.id);
const overlap = page1Ids.filter((id: string) => page2Ids.includes(id));
expect(overlap).toHaveLength(0);
});
});
响应标头验证
test('should return correct response headers', async ({ request }) => {
const response = await request.get('/users');
expect(response.headers()['content-type']).toContain('application/json');
expect(response.headers()['x-request-id']).toBeTruthy();
expect(response.headers()['cache-control']).toBeDefined();
// Security headers
expect(response.headers()['x-content-type-options']).toBe('nosniff');
expect(response.headers()['x-frame-options']).toBe('DENY');
});
响应时间断言
test('should respond within acceptable time', async ({ request }) => {
const start = Date.now();
const response = await request.get('/health');
const duration = Date.now() - start;
expect(response.status()).toBe(200);
expect(duration).toBeLessThan(500); // 500ms threshold
});
最佳实践
- 使用唯一的测试数据——在电子邮件和名称中加入时间戳或 UUID,以避免冲突。
- 在测试后清理——删除创建的资源,保持测试环境整洁。
- 验证响应架构——不仅要检查值,还要检查响应的结构。
- 测试成功和失败路径——务必测试错误情况和边界情况。
- 使用环境变量——切勿将 URL 或凭据硬编码。
- 按逻辑对测试分组——按资源或功能组织,而不是按 HTTP 方法组织。
- 使用 Fixture 进行身份验证——避免在每项测试中重复登录逻辑。
- 检查响应时间——API 性能也是正确性的一部分。
- 测试幂等性——验证重复的相同请求是否会产生一致结果。
- 对 API 测试进行版本管理——测试带版本的 API 时,按版本组织测试。
应避免的反模式
- 测试之间存在依赖——每项测试都必须创建自己的数据。
- 忽略响应标头——标头携带重要的元数据。
- 只测试状态码——还要始终验证响应正文。
- 使用硬编码 ID——ID 应来自测试设置,而不是硬编码值。
- 跳过错误场景——与成功路径测试相比,错误处理测试能发现更多 bug。
- 不测试不同角色——必须针对每个角色测试 API 授权。
- 混合 UI 测试和 API 测试——将 API 测试与 E2E 浏览器测试分开。
- 不验证副作用——如果 POST 创建了资源,就使用 GET 确认该资源。
- 忽略速率限制——测试是否强制执行速率限制,并处理 429 响应。
- 不使用大型负载进行测试——确保 API 能正确处理边界大小。
高级模式
使用上下文隔离的并行 API 测试
test.describe.parallel('Isolated API tests', () => {
test('test A creates and deletes user A', async ({ request }) => {
const res = await request.post('/users', {
data: { email: `a-${Date.now()}@test.com`, name: 'A', password: 'Pass123!' },
});
const user = await res.json();
await request.delete(`/users/${user.id}`);
});
test('test B creates and deletes user B', async ({ request }) => {
const res = await request.post('/users', {
data: { email: `b-${Date.now()}@test.com`, name: 'B', password: 'Pass123!' },
});
const user = await res.json();
await request.delete(`/users/${user.id}`);
});
});
带身份验证的自定义请求上下文
test('admin-only endpoint', async ({ playwright }) => {
const adminContext = await playwright.request.newContext({
baseURL: 'http://localhost:3000/api',
extraHTTPHeaders: {
Authorization: 'Bearer admin-token-here',
},
});
const response = await adminContext.get('/admin/settings');
expect(response.status()).toBe(200);
await adminContext.dispose();
});
通过 API 上传文件
import * as fs from 'fs';
import * as path from 'path';
test('should upload a file via API', async ({ request }) => {
const filePath = path.resolve('test-data/sample.pdf');
const fileBuffer = fs.readFileSync(filePath);
const response = await request.post('/files/upload', {
multipart: {
file: {
name: 'sample.pdf',
mimeType: 'application/pdf',
buffer: fileBuffer,
},
description: 'Test upload',
},
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.filename).toBe('sample.pdf');
expect(body.size).toBeGreaterThan(0);
});
评论
支持 Markdown