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

发布时间:2026/9/17 20:44:54
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 测试快 10100 倍稳没有选择器、渲染时序、动画等不稳定因素结果只取决于服务端真实行为准直接验证响应状态码、头部、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.extendApiFixtures({ // 静态 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-urlencodedOAuth token 端点常见而data用于 JSON bodyrequest对象的get/post/put/patch/delete方法与fetch的语义一一对应。3. 专用 API 测试项目的配置适用场景维护一套完全不需要浏览器的 API 测试套件。在playwright.config.ts中通过projects把 API 测试与 E2E 测试拆分为独立项目各自指定testDir、baseURL与请求头// 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 --projectapi只跑纯 API 套件--projecte2e只跑浏览器套件两者互不干扰也方便在 CI 上分阶段执行。仓库里的 playwright.config.ts 与 playwright.auth.config.ts 正是按项目职责拆分配置的实例。4. 响应断言状态码、头部与结构适用场景校验响应状态、请求头与 body 结构。避免场景永远不要跳过——每个 API 测试都必须断言状态码与 body。断言有清晰的优先级先状态码再关键头部最后才是 body 结构。expect的toMatchObject做部分匹配忽略不关心的字段expect.any(Type)做类型检查expect.arrayContaining与expect.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 dont 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 操作快 10100 倍。避免场景测试本身就是要验证 UI 的创建流程——此时应走 UI 完成创建。播种的核心是fixture 模式测试开始时用 API 创建数据await use(...)把数据暴露给测试体测试结束后fixture 返回时再调用 DELETE 清理。嵌套 fixtureseedWorkspace依赖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.extendSeedFixtures({ 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.fetchnumber(count(*[_type book title match $prefix]), { prefix: ${word}*, }), {intervals: [500, 1_000, 2_000], timeout: 30_000}, ) .toBe(1)而 createUniqueDocument.ts 封装了用 uuid 生成唯一_idclient.create异步可见写入的播种辅助函数与文档中的创建资源并使用返回 ID原则完全一致export async function createUniqueDocument( client: SanityClient, {_type, _id, ...restProps}: SanityDocumentStub, ): PromisePartialSanityDocument { 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选项接受文件对象namemimeTypebuffer与普通表单字段混用用fs.readFileSync把磁盘上的测试文件读成 Bufferimport {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) })注意 413Payload Too Large测试不需要真实 11MB 文件落盘——直接在内存里Buffer.alloc即可这也体现了纯 API 测试轻量、无 UI 依赖的优势。8. 链式 API 调用多步骤工作流与状态机适用场景测试多步骤流程——创建、读取、更新、删除序列订单流程状态机迁移。避免场景每个端点可以独立测试且交互琐碎时不要强行串联。完整订单工作流把前一个请求的返回值product.id、cart.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}) })状态机迁移发布工作流对草稿 → 审核中 → 已发布的发布流程测试合法迁移与非法迁移已发布不可回退到草稿应返回 422test(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让你以编程方式收集所有失败 issueschema 本身即契约文档前后端联调时也可直接复用。契约测试运行只需毫秒级非常适合放进 CI 做回归防线。Decision GuideAPI 测试还是 E2E 测试场景用 API 测试用 E2E 测试原因校验响应状态/body/headers是否无需浏览器快 10100 倍测试业务逻辑计算、规则是否API 测试把后端逻辑与 UI 隔离验证表单提交创建了正确数据播种用 API提交用 UI是UI 测试验证表单API 检查确认持久化测试展示给用户的错误消息否是错误渲染是 UI 关注点验证分页、过滤、排序是视情况两者皆可正确性用 API 测试仅当 UI 逻辑复杂时加 E2E为 E2E 测试播种数据是fixture否API 播种快速可靠测试认证流程登录/登出/RBACtoken/会话逻辑用 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-Type、Cache-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 测试用独立的requestfixtureTroubleshooting四个高频故障与修复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),仅供参考