# 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({ 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({ 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`。 #### 方案 A:Zod(推荐用于 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 // 值约束 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 与模拟它们