Files
2026-07-13 21:36:47 +08:00

1633 lines
52 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API 测试
> **使用场景**:直接测试 REST 或 GraphQL API——验证端点、填充测试数据、或在没有浏览器开销的情况下验证后端行为。
> **前置条件**[core/configuration.md](configuration.md) 中 `baseURL` 的设置,[core/fixtures-and-hooks.md](fixtures-and-hooks.md) 中的自定义 fixture 模式。
## 快速参考
```typescript
// 独立的 API 测试——不启动浏览器
import { test, expect } from "@playwright/test"
test("GET /api/users 返回用户列表", async ({ request }) => {
const response = await request.get("/api/users")
expect(response.status()).toBe(200)
expect(response.headers()["content-type"]).toContain("application/json")
const body = await response.json()
expect(body.users).toHaveLength(3)
expect(body.users[0]).toMatchObject({ id: expect.any(Number), email: expect.any(String) })
})
```
## 模式
### APIRequestContext 基础
**使用场景**:在任何测试中发起 HTTP 请求——支持 GET、POST、PUT、PATCH、DELETE,可携带请求头、查询参数和请求体。
**避免场景**:需要测试浏览器渲染的响应(重定向、通过 `Set-Cookie` 设置的 `HttpOnly` cookie)。请改用浏览器测试。
`request` fixture 提供了一个预先配置好的 `APIRequestContext`,它从配置中继承 `baseURL`。不会启动浏览器。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("通过 API 进行 CRUD 操作", async ({ request }) => {
// 带查询参数的 GET
const listResponse = await request.get("/api/users", {
params: { page: 1, limit: 10, role: "admin" },
})
expect(listResponse.ok()).toBeTruthy()
// 带 JSON 请求体的 POST
const createResponse = await request.post("/api/users", {
data: {
name: "Jane Doe",
email: "jane@example.com",
role: "editor",
},
})
expect(createResponse.status()).toBe(201)
const created = await createResponse.json()
// PUT —— 全量替换
const updateResponse = await request.put(`/api/users/${created.id}`, {
data: {
name: "Jane Smith",
email: "jane.smith@example.com",
role: "editor",
},
})
expect(updateResponse.ok()).toBeTruthy()
// PATCH —— 部分更新
const patchResponse = await request.patch(`/api/users/${created.id}`, {
data: { role: "admin" },
})
expect(patchResponse.ok()).toBeTruthy()
const patched = await patchResponse.json()
expect(patched.role).toBe("admin")
// DELETE
const deleteResponse = await request.delete(`/api/users/${created.id}`)
expect(deleteResponse.status()).toBe(204)
// 验证删除
const getDeleted = await request.get(`/api/users/${created.id}`)
expect(getDeleted.status()).toBe(404)
})
test("自定义请求头和认证令牌", async ({ request }) => {
const response = await request.get("/api/protected/resource", {
headers: {
Authorization: "Bearer eyJhbGciOiJIUzI1NiIs...",
"X-Request-ID": "test-correlation-id-123",
Accept: "application/json",
},
})
expect(response.ok()).toBeTruthy()
})
test("form-urlencoded 请求体", async ({ request }) => {
const response = await request.post("/api/oauth/token", {
form: {
grant_type: "client_credentials",
client_id: "my-app",
client_secret: "secret-value",
},
})
expect(response.ok()).toBeTruthy()
const token = await response.json()
expect(token).toHaveProperty("access_token")
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("通过 API 进行 CRUD 操作", async ({ request }) => {
const listResponse = await request.get("/api/users", {
params: { page: 1, limit: 10, role: "admin" },
})
expect(listResponse.ok()).toBeTruthy()
const createResponse = await request.post("/api/users", {
data: {
name: "Jane Doe",
email: "jane@example.com",
role: "editor",
},
})
expect(createResponse.status()).toBe(201)
const created = await createResponse.json()
const updateResponse = await request.put(`/api/users/${created.id}`, {
data: {
name: "Jane Smith",
email: "jane.smith@example.com",
role: "editor",
},
})
expect(updateResponse.ok()).toBeTruthy()
const patchResponse = await request.patch(`/api/users/${created.id}`, {
data: { role: "admin" },
})
expect(patchResponse.ok()).toBeTruthy()
const deleteResponse = await request.delete(`/api/users/${created.id}`)
expect(deleteResponse.status()).toBe(204)
})
test("form-urlencoded 请求体", async ({ request }) => {
const response = await request.post("/api/oauth/token", {
form: {
grant_type: "client_credentials",
client_id: "my-app",
client_secret: "secret-value",
},
})
expect(response.ok()).toBeTruthy()
const token = await response.json()
expect(token).toHaveProperty("access_token")
})
```
### API 测试结构
**使用场景**:编写不需要浏览器的专用 API 测试套件。
**避免场景**:需要在 API 调用之后对 UI 状态进行断言——请使用同时包含 `page``request` fixture 的组合测试。
将 API 测试组织在各自的目录中,并按资源或领域使用描述性的 `describe` 块。
**TypeScript**
```typescript
// tests/api/users.spec.ts
import { test, expect } from "@playwright/test"
// 不启动浏览器——这些测试只使用 request fixture
test.describe("Users API", () => {
test.describe("GET /api/users", () => {
test("返回分页用户列表", async ({ request }) => {
const response = await request.get("/api/users", {
params: { page: 1, limit: 5 },
})
expect(response.status()).toBe(200)
const body = await response.json()
expect(body.users.length).toBeLessThanOrEqual(5)
expect(body.pagination).toMatchObject({
page: 1,
limit: 5,
total: expect.any(Number),
})
})
test("按角色筛选", async ({ request }) => {
const response = await request.get("/api/users", {
params: { role: "admin" },
})
const body = await response.json()
for (const user of body.users) {
expect(user.role).toBe("admin")
}
})
})
test.describe("POST /api/users", () => {
test("使用有效数据创建新用户", async ({ request }) => {
const response = await request.post("/api/users", {
data: { name: "Test User", email: `test-${Date.now()}@example.com` },
})
expect(response.status()).toBe(201)
const user = await response.json()
expect(user).toMatchObject({
id: expect.any(Number),
name: "Test User",
})
})
test("拒绝重复邮箱", async ({ request }) => {
const email = `dupe-${Date.now()}@example.com`
await request.post("/api/users", { data: { name: "First", email } })
const response = await request.post("/api/users", {
data: { name: "Second", email },
})
expect(response.status()).toBe(409)
const body = await response.json()
expect(body.error).toContain("already exists")
})
})
})
```
**JavaScript**
```javascript
// tests/api/users.spec.js
const { test, expect } = require("@playwright/test")
test.describe("Users API", () => {
test.describe("GET /api/users", () => {
test("返回分页用户列表", async ({ request }) => {
const response = await request.get("/api/users", {
params: { page: 1, limit: 5 },
})
expect(response.status()).toBe(200)
const body = await response.json()
expect(body.users.length).toBeLessThanOrEqual(5)
expect(body.pagination).toMatchObject({
page: 1,
limit: 5,
total: expect.any(Number),
})
})
})
test.describe("POST /api/users", () => {
test("使用有效数据创建新用户", async ({ request }) => {
const response = await request.post("/api/users", {
data: { name: "Test User", email: `test-${Date.now()}@example.com` },
})
expect(response.status()).toBe(201)
const user = await response.json()
expect(user).toMatchObject({
id: expect.any(Number),
name: "Test User",
})
})
})
})
```
**配置提示**:使用专用项目来运行 API 测试,避免启动浏览器。
```typescript
// playwright.config.ts —— API 项目不启动浏览器运行
import { defineConfig } from "@playwright/test"
export default defineConfig({
projects: [
{
name: "api",
testDir: "./tests/api",
use: {
baseURL: "https://api.example.com",
extraHTTPHeaders: {
Accept: "application/json",
},
},
},
{
name: "e2e",
testDir: "./tests/e2e",
use: {
baseURL: "https://app.example.com",
browserName: "chromium",
},
},
],
})
```
### Request Fixtures
**使用场景**:多个测试需要一个已认证的 API 客户端,或者你想在测试套件中共享请求配置(请求头、base URL、认证令牌)。
**避免场景**:单个测试只需进行一次性的 API 调用。请直接使用内置的 `request` fixture。
**TypeScript**
```typescript
// fixtures/api-fixtures.ts
import { test as base, expect, APIRequestContext } from "@playwright/test"
type ApiFixtures = {
authenticatedRequest: APIRequestContext
adminRequest: APIRequestContext
}
export const test = base.extend<ApiFixtures>({
authenticatedRequest: async ({ playwright }, use) => {
// 创建一个带有认证头的新上下文
const context = await playwright.request.newContext({
baseURL: "https://api.example.com",
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
Accept: "application/json",
},
})
await use(context)
await context.dispose()
},
adminRequest: async ({ playwright }, use) => {
// 通过 API 登录获取令牌,然后创建认证上下文
const loginContext = await playwright.request.newContext({
baseURL: "https://api.example.com",
})
const loginResponse = await loginContext.post("/api/auth/login", {
data: {
email: process.env.ADMIN_EMAIL,
password: process.env.ADMIN_PASSWORD,
},
})
expect(loginResponse.ok()).toBeTruthy()
const { token } = await loginResponse.json()
await loginContext.dispose()
const context = await playwright.request.newContext({
baseURL: "https://api.example.com",
extraHTTPHeaders: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
})
await use(context)
await context.dispose()
},
})
export { expect }
```
```typescript
// tests/api/admin.spec.ts
import { test, expect } from "../../fixtures/api-fixtures"
test("管理员可以列出所有用户", async ({ adminRequest }) => {
const response = await adminRequest.get("/api/admin/users")
expect(response.status()).toBe(200)
const body = await response.json()
expect(body.users.length).toBeGreaterThan(0)
})
test("管理员可以删除用户", async ({ adminRequest }) => {
// 先创建再删除
const createResp = await adminRequest.post("/api/users", {
data: { name: "To Delete", email: `del-${Date.now()}@example.com` },
})
const { id } = await createResp.json()
const deleteResp = await adminRequest.delete(`/api/users/${id}`)
expect(deleteResp.status()).toBe(204)
})
```
**JavaScript**
```javascript
// fixtures/api-fixtures.js
const { test: base, expect } = require("@playwright/test")
const test = base.extend({
authenticatedRequest: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: "https://api.example.com",
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
Accept: "application/json",
},
})
await use(context)
await context.dispose()
},
adminRequest: async ({ playwright }, use) => {
const loginContext = await playwright.request.newContext({
baseURL: "https://api.example.com",
})
const loginResponse = await loginContext.post("/api/auth/login", {
data: {
email: process.env.ADMIN_EMAIL,
password: process.env.ADMIN_PASSWORD,
},
})
expect(loginResponse.ok()).toBeTruthy()
const { token } = await loginResponse.json()
await loginContext.dispose()
const context = await playwright.request.newContext({
baseURL: "https://api.example.com",
extraHTTPHeaders: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
})
await use(context)
await context.dispose()
},
})
module.exports = { test, expect }
```
```javascript
// tests/api/admin.spec.js
const { test, expect } = require("../../fixtures/api-fixtures")
test("管理员可以列出所有用户", async ({ adminRequest }) => {
const response = await adminRequest.get("/api/admin/users")
expect(response.status()).toBe(200)
const body = await response.json()
expect(body.users.length).toBeGreaterThan(0)
})
```
### JSON 响应断言
**使用场景**:在每次 API 调用后验证响应状态码、请求头和响应体结构。
**避免场景**:永远不要跳过这些步骤。每个 API 测试都应对状态码和响应体进行断言。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("全面的响应验证", async ({ request }) => {
const response = await request.get("/api/users/42")
// 状态码——始终先检查
expect(response.status()).toBe(200)
// 状态分类——ok() 检查 200-299 范围
expect(response.ok()).toBeTruthy()
// 响应头
expect(response.headers()["content-type"]).toContain("application/json")
expect(response.headers()["x-request-id"]).toBeDefined()
expect(response.headers()["cache-control"]).toMatch(/max-age=\d+/)
// 完整解析响应体并进行深度断言
const user = await response.json()
// 对已知字段进行精确匹配
expect(user.id).toBe(42)
expect(user.name).toBe("Jane Doe")
expect(user.email).toBe("jane@example.com")
// 部分匹配——忽略不关心的字段
expect(user).toMatchObject({
id: 42,
name: "Jane Doe",
role: expect.stringMatching(/^(admin|editor|viewer)$/),
})
// 使用 expect.any() 进行类型检查
expect(user).toMatchObject({
id: expect.any(Number),
name: expect.any(String),
createdAt: expect.any(String),
permissions: expect.any(Array),
})
// 数组内容
expect(user.permissions).toEqual(expect.arrayContaining(["read", "write"]))
expect(user.permissions).not.toContain("delete")
// 嵌套对象断言
expect(user.profile).toMatchObject({
avatar: expect.stringMatching(/^https:\/\//),
bio: expect.any(String),
})
// 日期格式验证
expect(new Date(user.createdAt).toISOString()).toBe(user.createdAt)
})
test("列表响应结构", async ({ request }) => {
const response = await request.get("/api/users")
const body = await response.json()
// 数组长度
expect(body.users).toHaveLength(10)
// 数组中的每一项都符合结构
for (const user of body.users) {
expect(user).toMatchObject({
id: expect.any(Number),
name: expect.any(String),
email: expect.stringContaining("@"),
})
}
// 分页元数据
expect(body.pagination).toEqual({
page: 1,
limit: 10,
total: expect.any(Number),
totalPages: expect.any(Number),
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("全面的响应验证", async ({ request }) => {
const response = await request.get("/api/users/42")
expect(response.status()).toBe(200)
expect(response.ok()).toBeTruthy()
expect(response.headers()["content-type"]).toContain("application/json")
const user = await response.json()
expect(user).toMatchObject({
id: 42,
name: "Jane Doe",
role: expect.stringMatching(/^(admin|editor|viewer)$/),
})
expect(user).toMatchObject({
id: expect.any(Number),
name: expect.any(String),
createdAt: expect.any(String),
permissions: expect.any(Array),
})
expect(user.permissions).toEqual(expect.arrayContaining(["read", "write"]))
})
test("列表响应结构", async ({ request }) => {
const response = await request.get("/api/users")
const body = await response.json()
expect(body.users).toHaveLength(10)
for (const user of body.users) {
expect(user).toMatchObject({
id: expect.any(Number),
name: expect.any(String),
email: expect.stringContaining("@"),
})
}
})
```
### GraphQL 测试
**使用场景**:你的后端暴露了 GraphQL API,你想测试查询、变更、变量和错误处理。
**避免场景**:你的 API 是纯 REST 的。请改用标准的 HTTP 方法。
所有 GraphQL 请求都通过 `POST` 发送到单个端点。在 JSON 请求体中发送 `query``variables` 以及可选的 `operationName`
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
const GRAPHQL_ENDPOINT = "/graphql"
test.describe("GraphQL API", () => {
test("带变量的查询", async ({ request }) => {
const response = await request.post(GRAPHQL_ENDPOINT, {
data: {
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts {
id
title
}
}
}
`,
variables: { id: "42" },
},
})
expect(response.ok()).toBeTruthy()
const { data, errors } = await response.json()
// GraphQL 即使出错也返回 200——始终同时检查两者
expect(errors).toBeUndefined()
expect(data.user).toMatchObject({
id: "42",
name: expect.any(String),
email: expect.stringContaining("@"),
})
expect(data.user.posts).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: expect.any(String), title: expect.any(String) }),
])
)
})
test("变更操作创建资源", async ({ request }) => {
const response = await request.post(GRAPHQL_ENDPOINT, {
data: {
query: `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
status
author {
id
}
}
}
`,
variables: {
input: {
title: "API Testing with Playwright",
body: "A comprehensive guide...",
status: "DRAFT",
},
},
},
})
const { data, errors } = await response.json()
expect(errors).toBeUndefined()
expect(data.createPost).toMatchObject({
id: expect.any(String),
title: "API Testing with Playwright",
status: "DRAFT",
})
})
test("处理 GraphQL 验证错误", async ({ request }) => {
const response = await request.post(GRAPHQL_ENDPOINT, {
data: {
query: `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
}
}
`,
variables: {
input: { title: "" }, // 无效:标题为空
},
},
})
// GraphQL 对验证错误也经常返回 200
const { data, errors } = await response.json()
expect(errors).toBeDefined()
expect(errors.length).toBeGreaterThan(0)
expect(errors[0].message).toContain("title")
expect(errors[0].extensions?.code).toBe("BAD_USER_INPUT")
})
test("处理授权错误", async ({ request }) => {
const response = await request.post(GRAPHQL_ENDPOINT, {
data: {
query: `
query AdminDashboard {
adminStats {
totalRevenue
activeUsers
}
}
`,
},
// 没有携带认证头
})
const { data, errors } = await response.json()
expect(errors).toBeDefined()
expect(errors[0].extensions?.code).toBe("UNAUTHORIZED")
expect(data?.adminStats).toBeNull()
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
const GRAPHQL_ENDPOINT = "/graphql"
test.describe("GraphQL API", () => {
test("带变量的查询", async ({ request }) => {
const response = await request.post(GRAPHQL_ENDPOINT, {
data: {
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`,
variables: { id: "42" },
},
})
const { data, errors } = await response.json()
expect(errors).toBeUndefined()
expect(data.user).toMatchObject({
id: "42",
name: expect.any(String),
email: expect.stringContaining("@"),
})
})
test("变更操作创建资源", async ({ request }) => {
const response = await request.post(GRAPHQL_ENDPOINT, {
data: {
query: `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
status
}
}
`,
variables: {
input: {
title: "API Testing with Playwright",
body: "A comprehensive guide...",
status: "DRAFT",
},
},
},
})
const { data, errors } = await response.json()
expect(errors).toBeUndefined()
expect(data.createPost).toMatchObject({
id: expect.any(String),
title: "API Testing with Playwright",
status: "DRAFT",
})
})
test("处理 GraphQL 验证错误", async ({ request }) => {
const response = await request.post(GRAPHQL_ENDPOINT, {
data: {
query: `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) { id }
}
`,
variables: { input: { title: "" } },
},
})
const { data, errors } = await response.json()
expect(errors).toBeDefined()
expect(errors.length).toBeGreaterThan(0)
expect(errors[0].message).toContain("title")
})
})
```
### API 数据填充
**使用场景**:E2E 测试在运行前需要特定数据存在。API 填充比基于 UI 的设置快 10-100 倍。
**避免场景**:测试专门验证通过 UI 的创建流程。除了正在测试的内容之外,其他所有数据都通过 API 填充。
**TypeScript**
```typescript
import { test as base, expect, APIRequestContext } from "@playwright/test"
// 在每个测试前通过 API 填充数据的 fixture
type SeedFixtures = {
seedUser: { id: number; email: string; password: string }
seedProject: { id: number; name: string }
}
export const test = base.extend<SeedFixtures>({
seedUser: async ({ request }, use) => {
const email = `user-${Date.now()}@example.com`
const password = "TestPass123!"
// 通过 API 创建
const response = await request.post("/api/users", {
data: { name: "Test User", email, password },
})
expect(response.ok()).toBeTruthy()
const user = await response.json()
// 传递给测试
await use({ id: user.id, email, password })
// 测试后清理——始终删除你创建的内容
await request.delete(`/api/users/${user.id}`)
},
seedProject: async ({ request, seedUser }, use) => {
const response = await request.post("/api/projects", {
data: { name: `Test Project ${Date.now()}`, ownerId: seedUser.id },
})
expect(response.ok()).toBeTruthy()
const project = await response.json()
await use({ id: project.id, name: project.name })
await request.delete(`/api/projects/${project.id}`)
},
})
export { expect }
```
```typescript
// tests/e2e/project-dashboard.spec.ts
import { test, expect } from "../../fixtures/seed-fixtures"
test("用户在仪表板上看到自己的项目", async ({ page, seedUser, seedProject }) => {
// 通过 UI 登录(或使用 storageState 提高速度)
await page.goto("/login")
await page.getByLabel("Email").fill(seedUser.email)
await page.getByLabel("Password").fill(seedUser.password)
await page.getByRole("button", { name: "Sign in" }).click()
// 数据已经存在——直接进行断言
await page.waitForURL("/dashboard")
await expect(page.getByRole("heading", { name: seedProject.name })).toBeVisible()
})
```
**JavaScript**
```javascript
// fixtures/seed-fixtures.js
const { test: base, expect } = require("@playwright/test")
const test = base.extend({
seedUser: async ({ request }, use) => {
const email = `user-${Date.now()}@example.com`
const password = "TestPass123!"
const response = await request.post("/api/users", {
data: { name: "Test User", email, password },
})
expect(response.ok()).toBeTruthy()
const user = await response.json()
await use({ id: user.id, email, password })
await request.delete(`/api/users/${user.id}`)
},
seedProject: async ({ request, seedUser }, use) => {
const response = await request.post("/api/projects", {
data: { name: `Test Project ${Date.now()}`, ownerId: seedUser.id },
})
expect(response.ok()).toBeTruthy()
const project = await response.json()
await use({ id: project.id, name: project.name })
await request.delete(`/api/projects/${project.id}`)
},
})
module.exports = { test, expect }
```
```javascript
// tests/e2e/project-dashboard.spec.js
const { test, expect } = require("../../fixtures/seed-fixtures")
test("用户在仪表板上看到自己的项目", async ({ page, seedUser, seedProject }) => {
await page.goto("/login")
await page.getByLabel("Email").fill(seedUser.email)
await page.getByLabel("Password").fill(seedUser.password)
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
await expect(page.getByRole("heading", { name: seedProject.name })).toBeVisible()
})
```
### 模式验证
**使用场景**:验证 API 响应是否符合契约——字段类型、必填字段、值约束。能够及早发现后端回归问题。
**避免场景**:你只需要检查一两个特定字段。请改用 `toMatchObject`
#### 方案 AZod(推荐用于 TypeScript 项目)
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
import { z } from "zod"
// 定义一次 schema,在多个测试中复用
const UserSchema = z.object({
id: z.number().positive(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(["admin", "editor", "viewer"]),
createdAt: z.string().datetime(),
profile: z.object({
avatar: z.string().url().nullable(),
bio: z.string().max(500),
}),
})
const PaginatedUsersSchema = z.object({
users: z.array(UserSchema),
pagination: z.object({
page: z.number().int().positive(),
limit: z.number().int().positive(),
total: z.number().int().nonnegative(),
}),
})
test("GET /api/users 符合 schema", async ({ request }) => {
const response = await request.get("/api/users")
expect(response.ok()).toBeTruthy()
const body = await response.json()
const result = PaginatedUsersSchema.safeParse(body)
if (!result.success) {
// 详细的错误输出,精确显示哪些字段失败
throw new Error(
`Schema 验证失败:\n${result.error.issues
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
.join("\n")}`
)
}
})
test("POST /api/users 返回有效用户", async ({ request }) => {
const response = await request.post("/api/users", {
data: { name: "Schema Test", email: `schema-${Date.now()}@example.com` },
})
const body = await response.json()
// 如果验证失败,会抛出包含详细路径信息的错误
UserSchema.parse(body)
})
```
#### 方案 B:手动类型检查(无需依赖)
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
function assertUserShape(user: unknown): void {
expect(user).toBeDefined()
expect(user).toMatchObject({
id: expect.any(Number),
name: expect.any(String),
email: expect.any(String),
role: expect.any(String),
createdAt: expect.any(String),
})
const u = user as Record<string, unknown>
// 值约束
expect(["admin", "editor", "viewer"]).toContain(u.role)
expect(typeof u.email === "string" && u.email.includes("@")).toBe(true)
expect(new Date(u.createdAt as string).toString()).not.toBe("Invalid Date")
}
test("响应符合预期结构", async ({ request }) => {
const response = await request.get("/api/users/1")
expect(response.ok()).toBeTruthy()
const body = await response.json()
assertUserShape(body)
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
function assertUserShape(user) {
expect(user).toBeDefined()
expect(user).toMatchObject({
id: expect.any(Number),
name: expect.any(String),
email: expect.any(String),
role: expect.any(String),
createdAt: expect.any(String),
})
expect(["admin", "editor", "viewer"]).toContain(user.role)
expect(user.email).toContain("@")
expect(new Date(user.createdAt).toString()).not.toBe("Invalid Date")
}
test("响应符合预期结构", async ({ request }) => {
const response = await request.get("/api/users/1")
expect(response.ok()).toBeTruthy()
const body = await response.json()
assertUserShape(body)
})
```
### 错误响应测试
**使用场景**:每个 API 都有错误路径。测试它们。今天缺少 401 测试,明天就是安全漏洞。
**避免场景**:永远不要跳过错误测试。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("错误响应", () => {
test("400 —— 带详细信息的验证错误", async ({ request }) => {
const response = await request.post("/api/users", {
data: { name: "", email: "not-an-email" }, // 无效数据
})
expect(response.status()).toBe(400)
const body = await response.json()
expect(body).toMatchObject({
error: "Validation Error",
details: expect.any(Array),
})
// 检查各个字段的错误
expect(body.details).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: "name", message: expect.any(String) }),
expect.objectContaining({ field: "email", message: expect.any(String) }),
])
)
})
test("401 —— 缺少认证信息", async ({ request }) => {
// 创建一个不带认证头的新上下文
const response = await request.get("/api/protected/resource", {
headers: { Authorization: "" }, // 明确清除
})
expect(response.status()).toBe(401)
const body = await response.json()
expect(body.error).toMatch(/unauthorized|unauthenticated/i)
})
test("403 —— 权限不足", async ({ request }) => {
// 假设 `request` 以 viewer 身份认证
const response = await request.delete("/api/admin/users/1")
expect(response.status()).toBe(403)
const body = await response.json()
expect(body.error).toMatch(/forbidden|insufficient permissions/i)
})
test("404 —— 资源未找到", async ({ request }) => {
const response = await request.get("/api/users/999999")
expect(response.status()).toBe(404)
const body = await response.json()
expect(body).toMatchObject({
error: expect.stringMatching(/not found/i),
})
})
test("409 —— 重复资源冲突", async ({ request }) => {
const email = `conflict-${Date.now()}@example.com`
await request.post("/api/users", {
data: { name: "First", email },
})
const response = await request.post("/api/users", {
data: { name: "Duplicate", email },
})
expect(response.status()).toBe(409)
})
test("422 —— 不可处理的实体", async ({ request }) => {
const response = await request.post("/api/orders", {
data: { items: [] }, // 空购物车
})
expect(response.status()).toBe(422)
const body = await response.json()
expect(body.error).toContain("at least one item")
})
test("429 —— 速率限制", async ({ request }) => {
// 发送快速请求以触发速率限制
const responses = await Promise.all(
Array.from({ length: 50 }, () => request.get("/api/search", { params: { q: "test" } }))
)
const rateLimited = responses.filter((r) => r.status() === 429)
expect(rateLimited.length).toBeGreaterThan(0)
// 验证速率限制头
const limited = rateLimited[0]
expect(limited.headers()["retry-after"]).toBeDefined()
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("错误响应", () => {
test("400 —— 带详细信息的验证错误", async ({ request }) => {
const response = await request.post("/api/users", {
data: { name: "", email: "not-an-email" },
})
expect(response.status()).toBe(400)
const body = await response.json()
expect(body).toMatchObject({
error: "Validation Error",
details: expect.any(Array),
})
expect(body.details).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: "name", message: expect.any(String) }),
expect.objectContaining({ field: "email", message: expect.any(String) }),
])
)
})
test("401 —— 缺少认证信息", async ({ request }) => {
const response = await request.get("/api/protected/resource", {
headers: { Authorization: "" },
})
expect(response.status()).toBe(401)
})
test("404 —— 资源未找到", async ({ request }) => {
const response = await request.get("/api/users/999999")
expect(response.status()).toBe(404)
})
test("409 —— 重复资源冲突", async ({ request }) => {
const email = `conflict-${Date.now()}@example.com`
await request.post("/api/users", { data: { name: "First", email } })
const response = await request.post("/api/users", {
data: { name: "Duplicate", email },
})
expect(response.status()).toBe(409)
})
})
```
### 通过 API 上传文件
**使用场景**:使用 multipart form-data 测试文件上传端点——文档上传、图片处理、CSV 导入。
**避免场景**:你需要测试浏览器文件选择对话框。请在 E2E 测试中使用 `page.setInputFiles()`
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
import path from "path"
import fs from "fs"
test("通过 multipart form-data 上传文件", async ({ request }) => {
const filePath = path.resolve("tests/fixtures/test-document.pdf")
const response = await request.post("/api/documents/upload", {
multipart: {
file: {
name: "test-document.pdf",
mimeType: "application/pdf",
buffer: fs.readFileSync(filePath),
},
description: "季度报告",
category: "reports",
},
})
expect(response.status()).toBe(201)
const body = await response.json()
expect(body).toMatchObject({
id: expect.any(String),
filename: "test-document.pdf",
mimeType: "application/pdf",
size: expect.any(Number),
url: expect.stringMatching(/^https:\/\//),
})
})
test("上传带元数据的图片", async ({ request }) => {
const imagePath = path.resolve("tests/fixtures/avatar.png")
const response = await request.post("/api/users/42/avatar", {
multipart: {
image: {
name: "avatar.png",
mimeType: "image/png",
buffer: fs.readFileSync(imagePath),
},
crop: JSON.stringify({ x: 0, y: 0, width: 200, height: 200 }),
},
})
expect(response.ok()).toBeTruthy()
const body = await response.json()
expect(body.avatarUrl).toMatch(/\.png$/)
})
test("上传多个文件", async ({ request }) => {
const files = ["report-q1.csv", "report-q2.csv"].map((name) => ({
name,
mimeType: "text/csv",
buffer: fs.readFileSync(path.resolve(`tests/fixtures/${name}`)),
}))
// 当 API 不支持批量上传时,顺序执行上传
const results = []
for (const file of files) {
const response = await request.post("/api/imports/csv", {
multipart: { file },
})
expect(response.ok()).toBeTruthy()
results.push(await response.json())
}
expect(results).toHaveLength(2)
})
test("拒绝超大文件", async ({ request }) => {
// 创建一个超出服务器限制的缓冲区
const largeBuffer = Buffer.alloc(11 * 1024 * 1024) // 11MB
const response = await request.post("/api/documents/upload", {
multipart: {
file: {
name: "large-file.bin",
mimeType: "application/octet-stream",
buffer: largeBuffer,
},
},
})
expect(response.status()).toBe(413) // Payload Too Large
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
const path = require("path")
const fs = require("fs")
test("通过 multipart form-data 上传文件", async ({ request }) => {
const filePath = path.resolve("tests/fixtures/test-document.pdf")
const response = await request.post("/api/documents/upload", {
multipart: {
file: {
name: "test-document.pdf",
mimeType: "application/pdf",
buffer: fs.readFileSync(filePath),
},
description: "季度报告",
category: "reports",
},
})
expect(response.status()).toBe(201)
const body = await response.json()
expect(body).toMatchObject({
id: expect.any(String),
filename: "test-document.pdf",
mimeType: "application/pdf",
size: expect.any(Number),
})
})
test("拒绝超大文件", async ({ request }) => {
const largeBuffer = Buffer.alloc(11 * 1024 * 1024)
const response = await request.post("/api/documents/upload", {
multipart: {
file: {
name: "large-file.bin",
mimeType: "application/octet-stream",
buffer: largeBuffer,
},
},
})
expect(response.status()).toBe(413)
})
```
### 链式 API 调用
**使用场景**:测试多步骤工作流——创建、读取、更新、删除序列;订单流程;状态机转换。这验证 API 作为一个集成系统的行为,而不仅仅是孤立的端点。
**避免场景**:你可以在隔离状态下测试每个端点,且交互逻辑很简单。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("完整的订单工作流", async ({ request }) => {
// 步骤 1:创建产品
const productResp = await request.post("/api/products", {
data: { name: "Widget", price: 29.99, stock: 100 },
})
expect(productResp.status()).toBe(201)
const product = await productResp.json()
// 步骤 2:创建购物车
const cartResp = await request.post("/api/carts", {
data: { items: [{ productId: product.id, quantity: 2 }] },
})
expect(cartResp.status()).toBe(201)
const cart = await cartResp.json()
expect(cart.total).toBe(59.98)
// 步骤 3:结算——从购物车创建订单
const orderResp = await request.post("/api/orders", {
data: {
cartId: cart.id,
shippingAddress: {
street: "123 Test St",
city: "Testville",
zip: "12345",
},
},
})
expect(orderResp.status()).toBe(201)
const order = await orderResp.json()
expect(order.status).toBe("pending")
expect(order.items).toHaveLength(1)
expect(order.total).toBe(59.98)
// 步骤 4:验证订单出现在用户的订单列表中
const ordersResp = await request.get("/api/orders")
const orders = await ordersResp.json()
expect(orders.items.map((o: any) => o.id)).toContain(order.id)
// 步骤 5:验证产品库存减少
const updatedProduct = await (await request.get(`/api/products/${product.id}`)).json()
expect(updatedProduct.stock).toBe(98) // 100 - 2
// 清理
await request.delete(`/api/orders/${order.id}`)
await request.delete(`/api/products/${product.id}`)
})
test("状态机转换——发布工作流", async ({ request }) => {
// 创建草稿文章
const createResp = await request.post("/api/posts", {
data: { title: "Draft Post", body: "Content here." },
})
const post = await createResp.json()
expect(post.status).toBe("draft")
// 提交审核
const reviewResp = await request.patch(`/api/posts/${post.id}/status`, {
data: { status: "in_review" },
})
expect(reviewResp.ok()).toBeTruthy()
expect((await reviewResp.json()).status).toBe("in_review")
// 批准(需要管理员权限——在实际测试中使用合适的 fixture)
const approveResp = await request.patch(`/api/posts/${post.id}/status`, {
data: { status: "published" },
})
expect(approveResp.ok()).toBeTruthy()
expect((await approveResp.json()).status).toBe("published")
// 验证:从已发布状态无法回退到草稿
const revertResp = await request.patch(`/api/posts/${post.id}/status`, {
data: { status: "draft" },
})
expect(revertResp.status()).toBe(422)
// 清理
await request.delete(`/api/posts/${post.id}`)
})
test("API + E2E 混合模式——通过 API 填充数据,在浏览器中验证", async ({ request, page }) => {
// 通过 API 填充测试数据
const resp = await request.post("/api/products", {
data: {
name: `混合测试产品 ${Date.now()}`,
price: 42.0,
published: true,
},
})
const product = await resp.json()
// 通过浏览器验证
await page.goto("/products")
await expect(page.getByRole("heading", { name: product.name })).toBeVisible()
await expect(page.getByText("$42.00")).toBeVisible()
// 通过 API 清理
await request.delete(`/api/products/${product.id}`)
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("完整的订单工作流", async ({ request }) => {
const productResp = await request.post("/api/products", {
data: { name: "Widget", price: 29.99, stock: 100 },
})
expect(productResp.status()).toBe(201)
const product = await productResp.json()
const cartResp = await request.post("/api/carts", {
data: { items: [{ productId: product.id, quantity: 2 }] },
})
expect(cartResp.status()).toBe(201)
const cart = await cartResp.json()
expect(cart.total).toBe(59.98)
const orderResp = await request.post("/api/orders", {
data: {
cartId: cart.id,
shippingAddress: {
street: "123 Test St",
city: "Testville",
zip: "12345",
},
},
})
expect(orderResp.status()).toBe(201)
const order = await orderResp.json()
expect(order.status).toBe("pending")
const ordersResp = await request.get("/api/orders")
const orders = await ordersResp.json()
expect(orders.items.map((o) => o.id)).toContain(order.id)
await request.delete(`/api/orders/${order.id}`)
await request.delete(`/api/products/${product.id}`)
})
test("API + E2E 混合模式——通过 API 填充数据,在浏览器中验证", async ({ request, page }) => {
const resp = await request.post("/api/products", {
data: {
name: `混合测试产品 ${Date.now()}`,
price: 42.0,
published: true,
},
})
const product = await resp.json()
await page.goto("/products")
await expect(page.getByRole("heading", { name: product.name })).toBeVisible()
await request.delete(`/api/products/${product.id}`)
})
```
## 决策指南
| 场景 | 使用 API 测试 | 使用 E2E 测试 | 原因 |
| ---------------------------------------------- | --------------------- | ----------------------- | --------------------------------------------------------------------- |
| 验证响应状态码/响应体/响应头 | 是 | 否 | 不需要浏览器;快 10-100 倍 |
| 测试业务逻辑(计算、规则) | 是 | 否 | API 测试将后端逻辑与 UI 分离 |
| 验证表单提交是否正确创建数据 | 通过 API 填充,通过 UI 提交 | 是 | UI 测试验证表单;API 检查确认持久化 |
| 测试向用户显示的错误消息 | 否 | 是 | 错误渲染是 UI 层关注的问题 |
| 验证分页、筛选、排序 | 是 | 可能两者都需要 | API 测试验证正确性;仅在 UI 逻辑复杂时才需要 E2E 测试 |
| 为 E2E 测试填充测试数据 | 是(fixture | 否 | API 填充快速且可靠 |
| 测试认证流程(登录/注销/RBAC) | 是(令牌/会话逻辑) | 是(UI 流程) | 两者都重要:API 保护资源,UI 引导用户 |
| 验证文件上传处理 | 是 | 仅在测试文件选择器 UI 时 | API 测试验证后端处理 |
| 契约/schema 回归测试 | 是 | 否 | Schema 测试毫秒级完成 |
| 测试第三方 webhook 处理 | 是 | 否 | Webhook 是 API 到 API 的通信;不涉及 UI |
| 验证操作后的重定向行为 | 否 | 是 | 重定向属于浏览器/导航范畴 |
| 测试实时更新(WebSocket + API 触发) | API 触发 | E2E 验证 | 通过 API 填充数据,在浏览器中观察 |
## 反模式
| 不要这样做 | 问题 | 应该这样做 |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| 使用 E2E 测试来验证纯 API 响应 | 速度慢、不稳定、无故启动浏览器 | 使用 `request` fixture——无需浏览器,直接 HTTP |
| 忽略 `response.status()` | 带有降级响应体的 500 错误可能通过所有响应体断言 | 始终先断言状态码:`expect(response.status()).toBe(200)` |
| 跳过响应头检查 | 缺少 `Content-Type``Cache-Control`、CORS 请求头会导致生产环境 bug | 断言关键请求头:`expect(response.headers()['content-type']).toContain('application/json')` |
| 只测试快乐路径 | 真实用户会遇到 400、401、403、404、409、422——每个都需要一个测试 | 专门用一个 `describe` 块来覆盖错误响应 |
| 在 API 测试中硬编码 ID | 数据库重置或 ID 重新分配后测试会失败 | 在测试中创建资源,使用返回的 ID |
| 在测试之间共享可变状态 | 依赖于执行顺序的测试不稳定,无法并行运行 | 每个测试创建并清理自己的数据 |
| 手动调用 `response.text()``JSON.parse()` | Playwright 的 `response.json()` 已经处理了这个,并在非 JSON 时抛出清晰的错误 | 使用 `await response.json()` |
| 创建资源后忘记清理 | 测试污染:后续测试可能看到过期数据或触发唯一约束 | 使用带清理的 fixture 或显式的 `delete` 调用 |
| 仅通过检查 `response.ok()` 来测试 GraphQL | GraphQL 即使出错也返回 200——`errors` 数组才是真正的信号 | 始终同时检查响应体中的 `data``errors` |
| 在不需要页面时使用 `page.request` | `page.request` 与浏览器上下文共享 cookie,可能导致认证混乱 | 纯 API 测试请使用独立的 `request` fixture |
## 故障排除
### "Request failed: connect ECONNREFUSED 127.0.0.1:3000"
**原因**API 服务器未运行,或 `baseURL` 指向了错误的主机/端口。
**修复方法**
- 在测试前确认服务器正在运行:在 `playwright.config.ts` 中使用 `webServer` 自动启动它。
- 检查配置中的 `baseURL` 是否与实际服务器地址一致。
```typescript
// playwright.config.ts
export default defineConfig({
webServer: {
command: "npm run start:api",
url: "http://localhost:3000/api/health",
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: "http://localhost:3000",
},
})
```
### "response.json() failed — body is not valid JSON"
**原因**:端点返回了 HTML(错误页面)、纯文本或空响应体,而非 JSON。
**修复方法**
- 先检查 `response.status()`——500 或 302 通常返回 HTML。
- 打印 `await response.text()` 来查看实际的响应体。
- 确认设置了 `Accept: application/json` 请求头。
```typescript
const response = await request.get("/api/endpoint")
if (!response.ok()) {
console.error(`状态码:${response.status()},响应体:${await response.text()}`)
}
const body = await response.json() // 现在你知道失败原因了
```
### 使用 `request` fixture 时出现"401 Unauthorized"
**原因**:内置的 `request` fixture 不会自动携带浏览器 cookie 或认证令牌。它从空白状态开始。
**修复方法**
- 在配置中设置 `extraHTTPHeaders`,或创建一个自定义的已认证 fixture。
- 如果你需要浏览器登录后的 cookie,请使用 `page.request`(它共享浏览器上下文的 cookie)替代独立的 `request` fixture。
```typescript
// 方案 A:配置级别的请求头
export default defineConfig({
use: {
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
},
})
// 方案 B:每次请求的请求头
const response = await request.get("/api/resource", {
headers: { Authorization: `Bearer ${token}` },
})
// 方案 C:使用 page.request 继承浏览器 cookie
test("带浏览器认证的 API 调用", async ({ page }) => {
await page.goto("/login")
// ... 通过 UI 登录 ...
const response = await page.request.get("/api/profile")
expect(response.ok()).toBeTruthy()
})
```
### GraphQL 返回 200 但 data 为 null
**原因**:GraphQL 服务器即使在查询出错时也返回 HTTP 200。实际错误在 `errors` 数组中。
**修复方法**:始终同时解构并检查 `data``errors`
```typescript
const { data, errors } = await response.json()
if (errors) {
console.error("GraphQL 错误:", JSON.stringify(errors, null, 2))
}
expect(errors).toBeUndefined()
expect(data.user).toBeDefined()
```
### 测试在本地通过但在 CI 中失败
**原因**:环境不同、数据库状态不同或缺少环境变量。
**修复方法**
- 使用 `process.env` 获取密钥和 base URL——永远不要硬编码它们。
-`globalSetup` 中运行数据库种子或迁移。
- 使用唯一标识符(时间戳、UUID)来标识测试数据,避免并行运行时的冲突。
- 检查 CI 的 `baseURL` 是否与部署的或容器化服务的地址一致。
## 相关文档
- [core/configuration.md](configuration.md) —— `baseURL``extraHTTPHeaders``webServer` 配置
- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) —— 可复用 API 客户端的自定义 fixture 模式
- [core/authentication.md](authentication.md) —— 包括基于令牌的 API 认证模式
- [core/network-mocking.md](network-mocking.md) —— 在 E2E 测试中模拟 API 响应(与本指南相反)
- [core/test-architecture.md](test-architecture.md) —— 何时使用 API 测试 vs E2E 测试 vs 组件测试
- [core/when-to-mock.md](when-to-mock.md) —— 何时调用真实 API 与模拟它们