test-api

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Playwright 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 utilities
text
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()
进行契约测试

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
schema.parse()
for runtime validation, not just type assertions:
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
test.each()
. Use a for-loop:
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缺乏原生
test.each()
,可使用for循环实现:
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
    z.strictObject()
    for contract testing
  • Types derived with
    z.infer<>
    (no separate type files)
  • Responses validated with
    schema.parse()
    at runtime
  • HTTP logic in client files, fixtures handle lifecycle only
  • Base paths centralized (not hardcoded everywhere)
  • Error cases use
    toMatchObject
    for single-assertion checks
  • 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
    进行单断言检查
  • 覆盖安全边缘场景(注入、畸形输入、大小限制)
  • 测试数据存于常量中(测试中无魔法字符串)