news 2026/9/20 10:50:38

Sanity 项目实践:用 Playwright 编写可靠 REST API 测试的完整指南——从 request fixture 到 Zod 契约校验

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Sanity 项目实践:用 Playwright 编写可靠 REST API 测试的完整指南——从 request fixture 到 Zod 契约校验

Sanity 项目实践:用 Playwright 编写可靠 REST API 测试的完整指南——从 request fixture 到 Zod 契约校验

【免费下载链接】sanitySanity Studio – Rapidly configure content workspaces powered by structured content项目地址: https://gitcode.com/GitHub_Trending/sa/sanity

本文以.agents/skills/playwright-best-practices/testing-patterns/api-testing.md为骨架,系统讲解在 Sanity(结构化内容工作台)这类以内容 API 为核心的工程中,如何用 Playwright 的request/APIRequestContext直接测试 REST 接口:包括认证客户端 fixtures、完整 CRUD、响应断言、API 数据播种、错误路径覆盖、multipart 文件上传、多步骤链式调用与 Zod 契约校验。文中所有模式都配有可直接复制的 TypeScript 代码,并结合本仓库e2e/目录下的真实实现(如 sanityClient.ts、search.spec.ts)给出落地佐证。读完你不仅能写出无浏览器开销的高性能 API 测试,还能掌握"API 播种 + 浏览器验证"的混合测试策略与常见故障的排查手段。

适用时机:直接测试 REST API——校验端点、播种测试数据、验证后端行为,完全不需要浏览器开销。相关文档:GraphQL 场景请参阅 graphql-testing.md。

为什么 API 测试需要与 E2E 测试区分开

Playwright 最广为人知的能力是浏览器自动化,但它的requestfixture 提供了独立于页面(Page)的纯 HTTP 请求能力。API 测试的价值在于:

  • :不需要启动浏览器、渲染页面、等待网络空闲,API 断言比等效的 UI 测试快 10~100 倍;
  • :没有选择器、渲染时序、动画等不稳定因素,结果只取决于服务端真实行为;
  • :直接验证响应状态码、头部、JSON 结构,能精确锁定后端逻辑而非 UI 表现层。

在本仓库的 Sanity e2e 体系中,这一定位非常清晰:e2e/目录同时包含浏览器端到端测试(e2e/tests/)与面向内容 API 的客户端封装(sanityClient.ts),测试在加载浏览器之前先通过 API 播种数据、在断言阶段又通过 API 验证持久化结果,正是本文要展开的核心思想。

Patterns:八大 API 测试模式

1. 为认证客户端建立 Request Fixtures

适用场景:多个测试需要共享同一配置的认证 API 客户端。避免场景:单个测试只需一次性 API 调用——直接用内置requestfixture 即可,不要为了复用而引入 fixtures 层。

test.extend允许你为测试注入自定义 fixture。playwright.request.newContext()创建独立的 APIRequestContext,不共享浏览器 cookie;每个 fixture 在await use(ctx)结束后调用dispose()释放连接,保证隔离与清理:

// fixtures/api-fixtures.ts import {test as base, expect, APIRequestContext} from '@playwright/test' type ApiFixtures = { authApi: APIRequestContext adminApi: APIRequestContext } export const test = base.extend<ApiFixtures>({ // 静态 token 型客户端:从环境变量读取,配置共享于所有测试 authApi: async ({playwright}, use) => { const ctx = await playwright.request.newContext({ baseURL: 'https://api.myapp.io', extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}`, Accept: 'application/json', }, }) await use(ctx) await ctx.dispose() }, // 动态 token 型客户端:先调用登录接口换取 token,再构建带认证的上下文 adminApi: async ({playwright}, use) => { const loginCtx = await playwright.request.newContext({ baseURL: 'https://api.myapp.io', }) const loginResp = await loginCtx.post('/auth/login', { data: { email: process.env.ADMIN_EMAIL, password: process.env.ADMIN_PASSWORD, }, }) expect(loginResp.ok()).toBeTruthy() const {token} = await loginResp.json() await loginCtx.dispose() const ctx = await playwright.request.newContext({ baseURL: 'https://api.myapp.io', extraHTTPHeaders: { Authorization: `Bearer ${token}`, Accept: 'application/json', }, }) await use(ctx) await ctx.dispose() }, }) export {expect}

测试文件从自定义 fixtures 导入test,即可直接使用adminApi等参数:

// tests/api/admin.spec.ts import {test, expect} from '../../fixtures/api-fixtures' test('admin retrieves all accounts', async ({adminApi}) => { const resp = await adminApi.get('/admin/accounts') expect(resp.status()).toBe(200) const body = await resp.json() expect(body.accounts.length).toBeGreaterThan(0) })

仓库佐证:Sanity 的 e2e 测试同样采用"共享认证客户端 + 生命周期管理"的思路。sanityClient.ts 中的TestContext持有@sanity/client实例(以SANITY_E2E_SESSION_TOKEN等环境变量初始化),通过getUniqueDocumentId()为每次测试生成唯一文档 ID,并在teardown()里用一条 GROQ 删除语句批量清理本测试创建的drafts.*文档——这正是"fixture 提供资源、测试用完即清"的实例化体现:

teardown(): void { void this.client.delete({ query: '*[_id in $ids]', params: {ids: [...this.documentIds].map((id) => `drafts.${id}`)}, }) }

2. CRUD 操作:GET / POST / PUT / PATCH / DELETE

适用场景:发起带请求头、查询参数、请求体的各类 HTTP 请求。避免场景:需要测试浏览器渲染行为(如重定向、HttpOnlycookie 处理)——那属于 E2E 范畴。

一个完整的 CRUD 生命周期测试,覆盖"创建 → 全量替换 → 局部更新 → 删除 → 验证删除",并演示查询参数与 JSON body 的用法:

import {test, expect} from '@playwright/test' test('full CRUD cycle', async ({request}) => { // GET with query params const listResp = await request.get('/api/items', { params: {page: 1, limit: 10, category: 'tools'}, }) expect(listResp.ok()).toBeTruthy() // POST with JSON body const createResp = await request.post('/api/items', { data: { title: 'Hammer', price: 19.99, category: 'tools', }, }) expect(createResp.status()).toBe(201) const created = await createResp.json() // PUT — full replacement const putResp = await request.put(`/api/items/${created.id}`, { data: { title: 'Claw Hammer', price: 24.99, category: 'tools', }, }) expect(putResp.ok()).toBeTruthy() // PATCH — partial update const patchResp = await request.patch(`/api/items/${created.id}`, { data: {price: 22.5}, }) expect(patchResp.ok()).toBeTruthy() const patched = await patchResp.json() expect(patched.price).toBe(22.5) // DELETE const delResp = await request.delete(`/api/items/${created.id}`) expect(delResp.status()).toBe(204) // Verify deletion const getDeleted = await request.get(`/api/items/${created.id}`) expect(getDeleted.status()).toBe(404) }) test('form-urlencoded body', async ({request}) => { const resp = await request.post('/oauth/token', { form: { grant_type: 'client_credentials', client_id: 'my-client', client_secret: 'secret-value', }, }) expect(resp.ok()).toBeTruthy() const token = await resp.json() expect(token).toHaveProperty('access_token') })

注意form选项用于application/x-www-form-urlencoded(OAuth token 端点常见),而data用于 JSON body;request对象的get/post/put/patch/delete方法与fetch的语义一一对应。

3. 专用 API 测试项目的配置

适用场景:维护一套完全不需要浏览器的 API 测试套件。

playwright.config.ts中通过projects把 API 测试与 E2E 测试拆分为独立项目,各自指定testDirbaseURL与请求头:

// playwright.config.ts import {defineConfig} from '@playwright/test' export default defineConfig({ projects: [ { name: 'api', testDir: './tests/api', use: { baseURL: 'https://api.myapp.io', extraHTTPHeaders: {Accept: 'application/json'}, }, }, { name: 'e2e', testDir: './tests/e2e', use: { baseURL: 'https://myapp.io', browserName: 'chromium', }, }, ], })

这样npx playwright test --project=api只跑纯 API 套件,--project=e2e只跑浏览器套件,两者互不干扰,也方便在 CI 上分阶段执行。仓库里的 playwright.config.ts 与 playwright.auth.config.ts 正是按项目职责拆分配置的实例。

4. 响应断言:状态码、头部与结构

适用场景:校验响应状态、请求头与 body 结构。避免场景:永远不要跳过——每个 API 测试都必须断言状态码与 body。

断言有清晰的优先级:先状态码,再关键头部,最后才是 body 结构。expecttoMatchObject做部分匹配(忽略不关心的字段),expect.any(Type)做类型检查,expect.arrayContainingexpect.stringMatching处理数组与枚举值:

import {test, expect} from '@playwright/test' test('comprehensive response validation', async ({request}) => { const resp = await request.get('/api/items/101') // Status code — always check first expect(resp.status()).toBe(200) expect(resp.ok()).toBeTruthy() // Headers expect(resp.headers()['content-type']).toContain('application/json') expect(resp.headers()['cache-control']).toMatch(/max-age=\d+/) const item = await resp.json() // Exact match on known fields expect(item.id).toBe(101) expect(item.title).toBe('Widget') // Partial match — ignore fields you don't care about expect(item).toMatchObject({ id: 101, title: 'Widget', status: expect.stringMatching(/^(active|inactive|archived)$/), }) // Type checks expect(item).toMatchObject({ id: expect.any(Number), title: expect.any(String), createdAt: expect.any(String), tags: expect.any(Array), }) // Array content expect(item.tags).toEqual(expect.arrayContaining(['featured'])) expect(item.tags).not.toContain('deprecated') // Nested object expect(item.metadata).toMatchObject({ views: expect.any(Number), rating: expect.any(Number), }) // Date format expect(new Date(item.createdAt).toISOString()).toBe(item.createdAt) }) test('list response structure', async ({request}) => { const resp = await request.get('/api/items') const body = await resp.json() expect(body.items).toHaveLength(10) for (const item of body.items) { expect(item).toMatchObject({ id: expect.any(Number), title: expect.any(String), price: expect.any(Number), }) } expect(body.pagination).toEqual({ page: 1, limit: 10, total: expect.any(Number), totalPages: expect.any(Number), }) })

日期断言技巧:new Date(str).toISOString() === str可以严格校验 ISO 8601 格式(含时区规范化),比正则更可靠。

5. API 数据播种:为 E2E 测试铺路

适用场景:E2E 测试需要特定数据先存在。API 播种比 UI 操作快 10~100 倍。避免场景:测试本身就是要验证 UI 的创建流程——此时应走 UI 完成创建。

播种的核心是fixture 模式:测试开始时用 API 创建数据,await use(...)把数据暴露给测试体,测试结束后(fixture 返回时)再调用 DELETE 清理。嵌套 fixture(seedWorkspace依赖seedAccount)用于有依赖关系的资源;时间戳保证数据唯一,避免与并行测试互相污染:

import {test as base, expect} from '@playwright/test' type SeedFixtures = { seedAccount: {id: number; email: string; password: string} seedWorkspace: {id: number; name: string} } export const test = base.extend<SeedFixtures>({ seedAccount: async ({request}, use) => { const email = `account-${Date.now()}@test.io` const password = 'SecurePass123!' const resp = await request.post('/api/accounts', { data: {name: 'Test Account', email, password}, }) expect(resp.ok()).toBeTruthy() const account = await resp.json() await use({id: account.id, email, password}) // Cleanup await request.delete(`/api/accounts/${account.id}`) }, seedWorkspace: async ({request, seedAccount}, use) => { const resp = await request.post('/api/workspaces', { data: {name: `Workspace ${Date.now()}`, ownerId: seedAccount.id}, }) expect(resp.ok()).toBeTruthy() const workspace = await resp.json() await use({id: workspace.id, name: workspace.name}) await request.delete(`/api/workspaces/${workspace.id}`) }, }) export {expect}

在 E2E 测试中,用播种好的账号走 UI 登录,再断言 UI 呈现了播种数据:

// tests/e2e/workspace-dashboard.spec.ts import {test, expect} from '../../fixtures/seed-fixtures' test('user sees workspace on dashboard', async ({page, seedAccount, seedWorkspace}) => { await page.goto('/login') await page.getByLabel('Email').fill(seedAccount.email) await page.getByLabel('Password').fill(seedAccount.password) await page.getByRole('button', {name: 'Sign in'}).click() await page.waitForURL('/dashboard') await expect(page.getByRole('heading', {name: seedWorkspace.name})).toBeVisible() })

仓库佐证:Sanity 的搜索测试 search.spec.ts 完整演示了这一模式——先用sanityClient.create()播种一篇带随机单词标题的 book 文档(随机前缀保证与共享数据集中历史运行遗留的文档区分),再用expect.poll+ GROQcount(*[_type == "book" && title match $prefix])轮询确认数据可被检索,之后才加载 Studio 执行 UI 搜索:

// 播种数据(加载浏览器之前,确保数据一定存在) await sanityClient.create({ _id: `drafts.${_testContext.getUniqueDocumentId()}`, _type: 'book', title, }) // 轮询 API 直到数据可被检索(异步索引有延迟) await expect .poll( () => sanityClient.fetch<number>('count(*[_type == "book" && title match $prefix])', { prefix: `${word}*`, }), {intervals: [500, 1_000, 2_000], timeout: 30_000}, ) .toBe(1)

而 createUniqueDocument.ts 封装了"用 uuid 生成唯一_id+client.create异步可见写入"的播种辅助函数,与文档中的"创建资源并使用返回 ID"原则完全一致:

export async function createUniqueDocument( client: SanityClient, {_type, _id, ...restProps}: SanityDocumentStub, ): Promise<Partial<SanityDocument>> { const doc = { _type, _id: _id || uuid(), ...restProps, } await client.create(doc, {visibility: 'async'}) return doc }

6. 错误响应测试:400 / 401 / 403 / 404 / 409 / 422 / 429

适用场景:每个 API 都有错误路径,必须测试。今天缺一个 401 测试,明天就是一个安全漏洞。

用一个test.describe('Error responses')块把常见错误状态码集中覆盖,这是成本最低、收益最高的 API 测试投资:

import {test, expect} from '@playwright/test' test.describe('Error responses', () => { test('400 — validation error with details', async ({request}) => { const resp = await request.post('/api/items', { data: {title: '', price: -5}, }) expect(resp.status()).toBe(400) const body = await resp.json() expect(body).toMatchObject({ error: 'Validation Error', details: expect.any(Array), }) expect(body.details).toEqual( expect.arrayContaining([ expect.objectContaining({ field: 'title', message: expect.any(String), }), expect.objectContaining({ field: 'price', message: expect.any(String), }), ]), ) }) test('401 — missing authentication', async ({request}) => { const resp = await request.get('/api/protected/resource', { headers: {Authorization: ''}, }) expect(resp.status()).toBe(401) const body = await resp.json() expect(body.error).toMatch(/unauthorized|unauthenticated/i) }) test('403 — insufficient permissions', async ({request}) => { const resp = await request.delete('/api/admin/items/1') expect(resp.status()).toBe(403) const body = await resp.json() expect(body.error).toMatch(/forbidden|insufficient permissions/i) }) test('404 — resource not found', async ({request}) => { const resp = await request.get('/api/items/999999') expect(resp.status()).toBe(404) const body = await resp.json() expect(body).toMatchObject({error: expect.stringMatching(/not found/i)}) }) test('409 — conflict on duplicate', async ({request}) => { const sku = `SKU-${Date.now()}` await request.post('/api/items', {data: {title: 'First', sku}}) const resp = await request.post('/api/items', { data: {title: 'Duplicate', sku}, }) expect(resp.status()).toBe(409) }) test('422 — unprocessable entity', async ({request}) => { const resp = await request.post('/api/orders', { data: {items: []}, }) expect(resp.status()).toBe(422) const body = await resp.json() expect(body.error).toContain('at least one item') }) test('429 — rate limiting', 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) expect(rateLimited[0].headers()['retry-after']).toBeDefined() }) })

要点:409 冲突测试用Date.now()生成唯一 sku,两次 POST 相同 sku 触发唯一约束;429 限流测试用Promise.all并发 50 个请求触发限流,并断言响应头携带retry-after

7. 通过 API 测试文件上传(multipart)

适用场景:测试 multipart 表单数据的上传端点。避免场景:要测试浏览器文件选择对话框——改用page.setInputFiles()

multipart选项接受文件对象(name+mimeType+buffer)与普通表单字段混用;用fs.readFileSync把磁盘上的测试文件读成 Buffer:

import {test, expect} from '@playwright/test' import path from 'path' import fs from 'fs' test('upload file via multipart', async ({request}) => { const filePath = path.resolve('tests/fixtures/report.pdf') const resp = await request.post('/api/documents/upload', { multipart: { file: { name: 'report.pdf', mimeType: 'application/pdf', buffer: fs.readFileSync(filePath), }, description: 'Monthly report', category: 'reports', }, }) expect(resp.status()).toBe(201) const body = await resp.json() expect(body).toMatchObject({ id: expect.any(String), filename: 'report.pdf', mimeType: 'application/pdf', size: expect.any(Number), url: expect.stringMatching(/^https:\/\//), }) }) test('rejects oversized files', async ({request}) => { const largeBuffer = Buffer.alloc(11 * 1024 * 1024) // 11MB const resp = await request.post('/api/documents/upload', { multipart: { file: { name: 'large-file.bin', mimeType: 'application/octet-stream', buffer: largeBuffer, }, }, }) expect(resp.status()).toBe(413) })

注意 413(Payload Too Large)测试不需要真实 11MB 文件落盘——直接在内存里Buffer.alloc即可,这也体现了纯 API 测试"轻量、无 UI 依赖"的优势。

8. 链式 API 调用:多步骤工作流与状态机

适用场景:测试多步骤流程——创建、读取、更新、删除序列,订单流程,状态机迁移。避免场景:每个端点可以独立测试且交互琐碎时,不要强行串联。

完整订单工作流

把前一个请求的返回值(product.idcart.id)作为后一个请求的输入,每一步都断言,最后统一清理:

import {test, expect} from '@playwright/test' test('complete order workflow', async ({request}) => { // Step 1: Create a product const productResp = await request.post('/api/products', { data: {name: 'Gadget', price: 49.99, stock: 50}, }) expect(productResp.status()).toBe(201) const product = await productResp.json() // Step 2: Create a cart const cartResp = await request.post('/api/carts', { data: {items: [{productId: product.id, quantity: 3}]}, }) expect(cartResp.status()).toBe(201) const cart = await cartResp.json() expect(cart.total).toBe(149.97) // Step 3: Checkout const orderResp = await request.post('/api/orders', { data: { cartId: cart.id, shippingAddress: { street: '456 Main Ave', city: 'Metropolis', zip: '54321', }, }, }) expect(orderResp.status()).toBe(201) const order = await orderResp.json() expect(order.status).toBe('pending') expect(order.items).toHaveLength(1) // Step 4: Verify order in list const ordersResp = await request.get('/api/orders') const orders = await ordersResp.json() expect(orders.items.map((o: any) => o.id)).toContain(order.id) // Step 5: Verify stock decreased const updatedProduct = await (await request.get(`/api/products/${product.id}`)).json() expect(updatedProduct.stock).toBe(47) // Cleanup await request.delete(`/api/orders/${order.id}`) await request.delete(`/api/products/${product.id}`) })
状态机迁移:发布工作流

对"草稿 → 审核中 → 已发布"的发布流程,测试合法迁移与非法迁移(已发布不可回退到草稿,应返回 422):

test('state machine transitions — publish workflow', async ({request}) => { const createResp = await request.post('/api/articles', { data: {title: 'Draft Article', body: 'Content here.'}, }) const article = await createResp.json() expect(article.status).toBe('draft') // Submit for review const reviewResp = await request.patch(`/api/articles/${article.id}/status`, { data: {status: 'in_review'}, }) expect(reviewResp.ok()).toBeTruthy() expect((await reviewResp.json()).status).toBe('in_review') // Approve const approveResp = await request.patch(`/api/articles/${article.id}/status`, { data: {status: 'published'}, }) expect(approveResp.ok()).toBeTruthy() expect((await approveResp.json()).status).toBe('published') // Cannot revert to draft from published const revertResp = await request.patch(`/api/articles/${article.id}/status`, { data: {status: 'draft'}, }) expect(revertResp.status()).toBe(422) await request.delete(`/api/articles/${article.id}`) })
API + E2E 混合:播种后进浏览器验证

这是最实用的混合模式——API 创建数据,浏览器只负责验证渲染结果:

test('API + E2E hybrid — seed via API, verify in browser', async ({request, page}) => { const resp = await request.post('/api/products', { data: { name: `Hybrid Product ${Date.now()}`, price: 35.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('$35.00')).toBeVisible() await request.delete(`/api/products/${product.id}`) })

9. 用 Zod 做 Schema 契约校验

适用场景:验证 API 响应符合契约——字段类型、必填字段、取值约束。避免场景:只查一两个具体字段时,用toMatchObject就够,不必引入 Schema。

把响应契约定义成 Zod schema,用safeParse校验,失败时把每个 issue 的路径与消息拼进错误信息,便于定位:

import {test, expect} from '@playwright/test' import {z} from 'zod' const ItemSchema = z.object({ id: z.number().positive(), title: z.string().min(1), price: z.number().nonnegative(), status: z.enum(['active', 'inactive', 'archived']), createdAt: z.string().datetime(), metadata: z.object({ views: z.number().int().nonnegative(), rating: z.number().min(0).max(5).nullable(), }), }) const PaginatedItemsSchema = z.object({ items: z.array(ItemSchema), pagination: z.object({ page: z.number().int().positive(), limit: z.number().int().positive(), total: z.number().int().nonnegative(), }), }) test('GET /api/items matches schema', async ({request}) => { const resp = await request.get('/api/items') expect(resp.ok()).toBeTruthy() const body = await resp.json() const result = PaginatedItemsSchema.safeParse(body) if (!result.success) { throw new Error( `Schema validation failed:\n${result.error.issues .map((i) => ` ${i.path.join('.')}: ${i.message}`) .join('\n')}`, ) } })

Zod 的safeParse(而非parse)让你以编程方式收集所有失败 issue;schema 本身即契约文档,前后端联调时也可直接复用。契约测试运行只需毫秒级,非常适合放进 CI 做回归防线。

Decision Guide:API 测试还是 E2E 测试

场景用 API 测试用 E2E 测试原因
校验响应状态/body/headers无需浏览器,快 10~100 倍
测试业务逻辑(计算、规则)API 测试把后端逻辑与 UI 隔离
验证表单提交创建了正确数据播种用 API,提交用 UIUI 测试验证表单;API 检查确认持久化
测试展示给用户的错误消息错误渲染是 UI 关注点
验证分页、过滤、排序视情况两者皆可正确性用 API 测试;仅当 UI 逻辑复杂时加 E2E
为 E2E 测试播种数据是(fixture)API 播种快速可靠
测试认证流程(登录/登出/RBAC)token/会话逻辑用 APIUI 流程用 E2E两者都重要:API 保护资源,UI 引导用户
验证文件上传处理仅当测文件选择器 UIAPI 测试验证后端处理
契约/Schema 回归测试Schema 测试毫秒级完成
测试第三方 webhook 处理Webhook 是 API 对 API,无 UI 参与
验证动作后的重定向行为重定向属于浏览器/导航关注点
测试实时更新(WebSocket + API 触发)API 负责触发E2E 负责验证用 API 播种,在浏览器中观察

Anti-Patterns:必须避开的 9 个坏习惯

不要这样做问题应该这样做
用 E2E 测试验证纯 API 响应慢、易碎,白白启动浏览器requestfixture——无浏览器,直接 HTTP
忽略response.status()带兜底 body 的 500 可能通过所有 body 断言永远先断言状态码:expect(response.status()).toBe(200)
跳过响应头检查缺失Content-TypeCache-Control、CORS 头会造成生产事故断言关键响应头
只测 happy path真实用户会触发 400、401、403、404、409、422——每一个都该有测试用专门的describe块覆盖错误响应
在 API 测试里硬编码 ID数据库重置或 ID 重新分配后测试即碎在测试中创建资源,使用返回的 ID
测试间共享可变状态依赖执行顺序的测试易碎且无法并行每个测试创建并清理自己的数据
手动response.text()JSON.parse()Playwright 的response.json()已处理并在非 JSON 时抛出清晰错误使用await response.json()
创建资源后忘记清理测试污染:后续测试看到过期数据或撞上唯一约束用带 teardown 的 fixture 或显式delete调用
不需要页面却用page.requestpage.request与浏览器上下文共享 cookie,可能造成认证混淆纯 API 测试用独立的requestfixture

Troubleshooting:四个高频故障与修复

"Request failed: connect ECONNREFUSED 127.0.0.1:3000"

原因:API 服务未启动,或baseURL指向了错误的主机/端口。修复:测试前确认服务在运行。在配置中用webServer自动启动:

// 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'}, })

reuseExistingServer: !process.env.CI让本地开发时复用已启动的服务器(提速),CI 中则总是由 Playwright 拉起全新实例。

"response.json() failed — body is not valid JSON"

原因:端点返回了 HTML(错误页)、纯文本或空 body,而不是 JSON。修复:先检查response.status()——500 或 302 通常返回 HTML。用response.text()打印实际 body 观察。确认设置了Accept: application/json头:

const resp = await request.get('/api/endpoint') if (!resp.ok()) { console.error(`Status: ${resp.status()}, Body: ${await resp.text()}`) } const body = await resp.json()

"401 Unauthorized" when usingrequestfixture

原因:内置requestfixture 不会自动携带浏览器 cookie 或认证 token。修复:在配置中设置extraHTTPHeaders,或创建自定义认证 fixture。如果确实需要浏览器登录产生的 cookie,改用page.request

// Option A: config-level headers export default defineConfig({ use: { extraHTTPHeaders: {Authorization: `Bearer ${process.env.API_TOKEN}`}, }, }) // Option B: per-request headers const resp = await request.get('/api/resource', { headers: {Authorization: `Bearer ${token}`}, }) // Option C: use page.request to inherit browser cookies test('API call with browser auth', async ({page}) => { await page.goto('/login') // ... login via UI ... const resp = await page.request.get('/api/profile') expect(resp.ok()).toBeTruthy() })

仓库佐证:Sanity e2e 体系对"token 从哪来"的边界同样处理得很严格——envVars.ts 中readEnv在缺失必需环境变量时直接抛错提示复制.env.examplereadBoolEnv则对true/1/yes做宽松布尔解析,确保 CI 与本地行为一致:

export function readEnv(name: KnownEnvVar): string { const val = findEnv(name) if (val === undefined) { throw new Error( `Missing required environment variable "${name}". Make sure to copy \`.env.example\` to \`.env.local\``, ) } return val }

Tests pass locally but fail in CI

原因:环境差异、数据库状态不同、环境变量缺失。修复:用process.env承载密钥与 baseURL;在globalSetup中执行数据库播种或迁移;测试数据使用唯一标识符(时间戳、UUID);确认 CI 的baseURL与部署服务匹配。

Sanity 的做法是值得借鉴的模板:globalSetup.ts 会在所有测试开始前打开一个真实浏览器访问 Studio 首页并等待users/me响应返回,确保开发服务器首屏的 JS 编译预热完成——这样每个测试套件都不必承担首次请求的编译惩罚,显著降低 CI 上的超时波动。这正是"全局设置消除环境差异"思想在本仓库中的落地。

结语:把 API 测试当作第一道防线

回到本文开头的定位:API 测试不是 E2E 的替代品,而是它的前置防线与加速器。合理的测试金字塔应当是——纯 API 测试requestfixture 覆盖契约、业务逻辑、错误路径与数据播种(毫秒级、零浏览器),E2E 测试专注于 UI 渲染、导航、错误消息展示等浏览器特有的行为(秒级、真实交互),两者通过"API 播种 + 浏览器验证"的混合模式衔接。Sanity 仓库的e2e/目录(sanityClient.ts、search.spec.ts、globalSetup.ts)已经为这套方法论提供了生产级参考实现,你可以直接把它当作自己项目 API 测试架构的设计蓝本。

【免费下载链接】sanitySanity Studio – Rapidly configure content workspaces powered by structured content项目地址: https://gitcode.com/GitHub_Trending/sa/sanity

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 2:22:51

SpringBoot接口安全:这5个漏洞必须堵上

SpringBoot 让接口开发变得飞快&#xff0c;但“快”往往意味着安全被抛在脑后。很多项目上线后&#xff0c;接口裸奔&#xff0c;被扫到就是一顿薅。以下5个漏洞&#xff0c;每一个都足以让你半夜被叫起来修数据。1. 接口裸奔&#xff1a;未授权访问与越权最常见的漏洞&#x…

作者头像 李华
网站建设 2026/9/20 8:17:49

【计算机毕业设计单片机案例】基于 STM32 或 51 单片机的环境监测婴幼儿智能安抚系统设计 基于 STM32 或 51 单片机的声光短信多级报警婴儿监护系统设计(025407)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机&#xff0c;Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华
网站建设 2026/9/20 22:36:33

揭秘哈希值:文件的神秘DNA指纹

文章目录看个类比DNA指纹技术那么哈希是啥&#xff1f;详解哈希函数的性质应用文件对比与错误处理密码存储与认证文件命名与存储攻击md5结语本文由Jzwalliser原创&#xff0c;发布在CSDN平台上&#xff0c;遵循CC 4.0 BY-NC-SA协议。 因此&#xff0c;若需转载/引用本文&#x…

作者头像 李华