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