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

52 KiB
Raw Permalink Blame History

API 测试

使用场景:直接测试 REST 或 GraphQL API——验证端点、填充测试数据、或在没有浏览器开销的情况下验证后端行为。 前置条件core/configuration.mdbaseURL 的设置,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 状态进行断言——请使用同时包含 pagerequest 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 请求体中发送 queryvariables 以及可选的 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

方案 AZod(推荐用于 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-TypeCache-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 数组才是真正的信号 始终同时检查响应体中的 dataerrors
在不需要页面时使用 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)替代独立的 request fixture。
// 方案 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 数组中。

修复方法:始终同时解构并检查 dataerrors

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 是否与部署的或容器化服务的地址一致。

相关文档