52 KiB
API 测试
使用场景:直接测试 REST 或 GraphQL API——验证端点、填充测试数据、或在没有浏览器开销的情况下验证后端行为。 前置条件:core/configuration.md 中
baseURL的设置,core/fixtures-and-hooks.md 中的自定义 fixture 模式。
快速参考
// 独立的 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
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
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
// 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
// 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 测试,避免启动浏览器。
// 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
// 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 }
// 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
// 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 }
// 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
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
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
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
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
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 }
// 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
// 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 }
// 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。
方案 A:Zod(推荐用于 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
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
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
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
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
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
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
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
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是否与实际服务器地址一致。
// 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请求头。
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)替代独立的requestfixture。
// 方案 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。
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 ——
baseURL、extraHTTPHeaders和webServer配置 - core/fixtures-and-hooks.md —— 可复用 API 客户端的自定义 fixture 模式
- core/authentication.md —— 包括基于令牌的 API 认证模式
- core/network-mocking.md —— 在 E2E 测试中模拟 API 响应(与本指南相反)
- core/test-architecture.md —— 何时使用 API 测试 vs E2E 测试 vs 组件测试
- core/when-to-mock.md —— 何时调用真实 API 与模拟它们