test-api
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePlaywright API Testing
Playwright API测试
Write API tests using Playwright's built-in HTTP client.
使用Playwright内置的HTTP客户端编写API测试。
Project Structure
项目结构
text
tests/
├── specs/ # Test files organized by feature/domain
├── fixtures/ # Playwright fixtures (lifecycle + wiring)
├── clients/ # HTTP client logic (endpoints, methods)
├── schemas/ # Zod schemas (source of truth for types)
├── constants/ # Test data, business rules
└── utils/ # Shared utilitiestext
tests/
├── specs/ # 按功能/领域组织的测试文件
├── fixtures/ # Playwright夹具(生命周期与依赖注入)
├── clients/ # HTTP客户端逻辑(端点、方法)
├── schemas/ # Zod schemas(类型的唯一可信来源)
├── constants/ # 测试数据、业务规则
└── utils/ # 共享工具函数Schema Validation with Zod
基于Zod的Schema验证
Use z.strictObject()
for Contract Testing
z.strictObject()使用z.strictObject()
进行契约测试
z.strictObject()Catches unexpected extra properties from the API:
typescript
// GOOD: Fails if API returns extra fields
export const UserSchema = z.strictObject({
id: z.string().uuid(),
email: z.string().email(),
createdAt: z.string(),
});
// BAD: Silently ignores extra fields
export const UserSchema = z.object({ ... });捕获API返回的意外额外属性:
typescript
// 推荐:若API返回额外字段则测试失败
export const UserSchema = z.strictObject({
id: z.string().uuid(),
email: z.string().email(),
createdAt: z.string(),
});
// 不推荐:静默忽略额外字段
export const UserSchema = z.object({ ... });Derive Types from Schemas
从Schemas派生类型
Single source of truth - never maintain separate type definitions:
typescript
// GOOD: Types derived from schemas
export const UserSchema = z.strictObject({ ... });
export type User = z.infer<typeof UserSchema>;
// BAD: Duplicate definitions that can drift apart
interface User { ... } // in types.ts
const UserSchema = z.object({ ... }) // in schemas.ts单一可信来源 - 无需维护单独的类型定义:
typescript
// 推荐:从schemas派生类型
export const UserSchema = z.strictObject({ ... });
export type User = z.infer<typeof UserSchema>;
// 不推荐:重复定义可能导致不一致
interface User { ... } // 在types.ts中
const UserSchema = z.object({ ... }) // 在schemas.ts中Actually Validate Responses
实际验证响应
Use for runtime validation, not just type assertions:
schema.parse()typescript
// GOOD: Runtime validation
const body = await response.json();
const user = UserResponseSchema.parse(body);
// BAD: Type assertion only (no runtime check)
const body: ApiResponse<User> = await response.json();使用进行运行时验证,而非仅类型断言:
schema.parse()typescript
// 推荐:运行时验证
const body = await response.json();
const user = UserResponseSchema.parse(body);
// 不推荐:仅类型断言(无运行时检查)
const body: ApiResponse<User> = await response.json();Layer Separation
分层架构
Client Layer (HTTP logic)
客户端层(HTTP逻辑)
typescript
// clients/user.client.ts
const BASE_PATH = "/api/users";
function userPath(...segments: string[]): string {
return [BASE_PATH, ...segments].join("/");
}
export async function createUser(
request: APIRequestContext,
data: CreateUserInput,
): Promise<User> {
const response = await request.post(userPath(), { data });
return UserResponseSchema.parse(await response.json()).data;
}
// Raw variant for error testing (no validation, returns raw response)
export function createUserRaw(
request: APIRequestContext,
data: unknown,
): Promise<APIResponse> {
return request.post(userPath(), { data });
}typescript
// clients/user.client.ts
const BASE_PATH = "/api/users";
function userPath(...segments: string[]): string {
return [BASE_PATH, ...segments].join("/");
}
export async function createUser(
request: APIRequestContext,
data: CreateUserInput,
): Promise<User> {
const response = await request.post(userPath(), { data });
return UserResponseSchema.parse(await response.json()).data;
}
// 用于错误测试的原始变体(无验证,返回原始响应)
export function createUserRaw(
request: APIRequestContext,
data: unknown,
): Promise<APIResponse> {
return request.post(userPath(), { data });
}Fixtures (lifecycle + wiring)
夹具(生命周期与依赖注入)
Fixtures delegate to clients and handle cleanup:
typescript
// fixtures/user.fixture.ts
import * as userClient from "../clients/user.client";
export const test = base.extend<UserFixtures>({
userApi: async ({ request }, use) => {
const created = new Set<string>();
const api = {
create: async (data: CreateUserInput) => {
const user = await userClient.createUser(request, data);
created.add(user.id);
return user;
},
createRaw: (data: unknown) => userClient.createUserRaw(request, data),
delete: (id: string) => userClient.deleteUser(request, id),
// ...
};
await use(api);
// Cleanup
for (const id of created) {
await userClient.deleteUser(request, id).catch(() => {});
}
},
});夹具委托给客户端并处理清理工作:
typescript
// fixtures/user.fixture.ts
import * as userClient from "../clients/user.client";
export const test = base.extend<UserFixtures>({
userApi: async ({ request }, use) => {
const created = new Set<string>();
const api = {
create: async (data: CreateUserInput) => {
const user = await userClient.createUser(request, data);
created.add(user.id);
return user;
},
createRaw: (data: unknown) => userClient.createUserRaw(request, data),
delete: (id: string) => userClient.deleteUser(request, id),
// ...
};
await use(api);
// 清理操作
for (const id of created) {
await userClient.deleteUser(request, id).catch(() => {});
}
},
});Test Patterns
测试模式
Error Response Testing
错误响应测试
Check everything in one assertion:
typescript
// GOOD: Single assertion checks all properties
const body = await response.json();
expect(body).toMatchObject({
success: false,
error: expect.stringMatching(/not found/i),
});
// AVOID: Sequential assertions stop on first failure
expect(body.success).toBe(false);
expect(body.error).toMatch(/not found/i);在单个断言中检查所有属性:
typescript
// 推荐:单个断言检查所有属性
const body = await response.json();
expect(body).toMatchObject({
success: false,
error: expect.stringMatching(/not found/i),
});
// 避免:顺序断言会在第一个失败时停止
expect(body.success).toBe(false);
expect(body.error).toMatch(/not found/i);Parameterized Tests
参数化测试
Playwright lacks native . Use a for-loop:
test.each()typescript
const cases = [
{ name: "empty string", input: "", status: 400 },
{ name: "too long", input: "x".repeat(256), status: 400 },
{ name: "valid", input: "test@example.com", status: 201 },
];
for (const { name, input, status } of cases) {
test(`email validation: ${name}`, async ({ userApi }) => {
const response = await userApi.createRaw({ email: input });
expect(response.status()).toBe(status);
});
}Or wrap in a helper if used frequently:
typescript
function testEach<T>(
cases: { name: string; data: T }[],
fn: (data: T) => Promise<void>,
) {
for (const { name, data } of cases) {
test(name, () => fn(data));
}
}Playwright缺乏原生,可使用for循环实现:
test.each()typescript
const cases = [
{ name: "空字符串", input: "", status: 400 },
{ name: "过长字符串", input: "x".repeat(256), status: 400 },
{ name: "有效格式", input: "test@example.com", status: 201 },
];
for (const { name, input, status } of cases) {
test(`邮箱验证:${name}`, async ({ userApi }) => {
const response = await userApi.createRaw({ email: input });
expect(response.status()).toBe(status);
});
}若频繁使用,可封装为工具函数:
typescript
function testEach<T>(
cases: { name: string; data: T }[],
fn: (data: T) => Promise<void>,
) {
for (const { name, data } of cases) {
test(name, () => fn(data));
}
}Security Tests
安全测试
Include basic security validation:
typescript
test.describe("Security", () => {
test("SQL injection in ID parameter", async ({ request }) => {
const response = await request.get("/api/users/1 OR 1=1");
expect([400, 404]).toContain(response.status());
});
test("rejects oversized payload", async ({ request }) => {
const response = await request.post("/api/users", {
data: { name: "x".repeat(1_000_000) },
});
expect(response.status()).toBe(413);
});
});包含基础安全验证:
typescript
test.describe("安全测试", () => {
test("ID参数中的SQL注入", async ({ request }) => {
const response = await request.get("/api/users/1 OR 1=1");
expect([400, 404]).toContain(response.status());
});
test("拒绝超大请求体", async ({ request }) => {
const response = await request.post("/api/users", {
data: { name: "x".repeat(1_000_000) },
});
expect(response.status()).toBe(413);
});
});Constants
常量管理
Centralize test data:
typescript
// constants/test-data.ts
export const TEST_USERS = {
VALID: { email: "test@example.com", name: "Test User" },
ADMIN: { email: "admin@example.com", name: "Admin", role: "admin" },
} as const;
// constants/business-rules.ts
export const LIMITS = {
MAX_NAME_LENGTH: 255,
MAX_ITEMS_PER_PAGE: 100,
} as const;集中管理测试数据:
typescript
// constants/test-data.ts
export const TEST_USERS = {
VALID: { email: "test@example.com", name: "Test User" },
ADMIN: { email: "admin@example.com", name: "Admin", role: "admin" },
} as const;
// constants/business-rules.ts
export const LIMITS = {
MAX_NAME_LENGTH: 255,
MAX_ITEMS_PER_PAGE: 100,
} as const;Checklist
检查清单
- Schemas use for contract testing
z.strictObject() - Types derived with (no separate type files)
z.infer<> - Responses validated with at runtime
schema.parse() - HTTP logic in client files, fixtures handle lifecycle only
- Base paths centralized (not hardcoded everywhere)
- Error cases use for single-assertion checks
toMatchObject - Security edge cases covered (injection, malformed input, size limits)
- Test data in constants (not magic strings in tests)
- Schemas使用进行契约测试
z.strictObject() - 通过派生类型(无单独类型文件)
z.infer<> - 使用在运行时验证响应
schema.parse() - HTTP逻辑在客户端文件中,夹具仅处理生命周期
- 基础路径集中管理(不硬编码到各处)
- 错误场景使用进行单断言检查
toMatchObject - 覆盖安全边缘场景(注入、畸形输入、大小限制)
- 测试数据存于常量中(测试中无魔法字符串)