ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

使用 Playwright 为 Dub 编写 HTTP API 测试:从文件布局到源码级实战指南

2026/9/11 22:41:59 拓冰建站 浏览量
使用 Playwright 为 Dub 编写 HTTP API 测试:从文件布局到源码级实战指南 使用 Playwright 为 Dub 编写 HTTP API 测试从文件布局到源码级实战指南【免费下载链接】dubThe modern link attribution platform. Loved by world-class marketing teams like Framer, Perplexity, Superhuman, Twilio, Buffer and more.项目地址: https://gitcode.com/GitHub_Trending/du/dubDub 是一个现代化的短链与归因平台其 API 层包含大量以工作区workspace为作用域的 Bearer 鉴权路由。本文基于仓库中的 playwright-api-tests 技能文档系统讲解如何为 Dub 的/api/*路由编写、扩展和运行 Playwright HTTP API 测试从种子数据与认证机制的底层实现、Spec 文件布局与必备约定到错误契约、分页边界和 Vitest 迁移的完整实战流程。读完本文你将掌握一套可直接复制、可重复运行、能在 CI 中稳定执行的 API 测试编写方法论。为什么用 Playwright 而不是 Vitest 测 APIDub 的测试体系按用途划分为三层各有归属不能混放测试类型位置技术栈适用场景单元 / HTTP API 测试apps/web/tests/Vitest组件与逻辑的快速单测已逐步迁出HTTP API 契约测试apps/web/playwright/api/Playwrightapiproject对/api/*路由做端到端 HTTP 断言浏览器端到端测试apps/web/playwright/partners/、playwright/workspaces/Playwright Desktop Chrome登录、引导流程、计费页面等 UI 场景API 测试统一放在apps/web/playwright/api/resource/*.spec.ts下。它们不要放进apps/web/tests/Vitest也不要放进playwright/partners或playwright/workspaces浏览器 e2e。API 测试不需要 MailHog、不需要浏览器登录只依赖一个 Bearer token因此既快又稳定非常适合作为后端路由的回归防线。认证与种子数据globalSetup 如何注入 tokenAPI 测试的认证是一次性种入的链路如下playwright/global-setup.ts └─ assertLocalDatabaseEnv() // 校验本地数据库环境 └─ setupTestWorkspace() // 幂等 upsert 用户/工作区/token/Program └─ 写入 playwright/.auth/api.json在 global-setup.ts 中globalSetup在任何 project 运行前先调用setupTestWorkspace()。这个函数位于 setup-test-workspace.ts其核心逻辑是用prisma.user.upsert按 email 幂等创建/更新专用测试用户playwright-apidub-internal-test.com创建playwright-api工作区plan: enterprise并设置 tags/links/domains/partners 等配额上限保证测试不受套餐配额限制为该用户创建工作区owner成员关系与通知偏好通过hashToken写入一条RestrictedToken固定值为dub_playwright_api_test_key_fixed注释明确说明这是仅限本地/CI 的固定密钥scopes: apis.all可以调用全部 API额外搭建一个 partner program默认域playwright-api.dub-internal-test.com、默认 URLhttps://example.com、默认分组 Partner Links、以及 lead/sale 两条佣金奖励记录见TEST_COMMISSION_REWARDS最后把所有凭据 JSON 序列化写入playwright/.auth/api.json字段包括token、workspaceId、workspaceSlug、programId、defaultGroupId、baseURL。整套逻辑使用upsert因此可以安全地重复执行多次而不产生重复数据。规范要求 Spec 中不得直接调用setupTestWorkspace——它只允许由globalSetup触发否则会破坏单例种子语义。文件布局一个资源一个目录API 测试的目录结构在 playwright/api/ 下可以完整看到规范约定的骨架如下apps/web/playwright/api/ ├── fixtures.ts # api workspace program fixtures复用勿重复实现 ├── setup-test-workspace.ts # 仅 globalSetup 使用只有认证/工作区种子需要变更时才扩展 ├── constants.ts # PLAYWRIGHT_API_BASE http://localhost:8888 └── resource/ ├── resource.spec.ts └── resource-pagination.spec.ts # 可选分页用例过多时拆分命名约定是kebab-case.spec.ts。仓库中实际存在的资源包括tags/、folders/、customers/含customers-pagination.spec.ts、workspaces/、domains/、partners/含ban-partner.spec.ts、campaigns/、commissions/、conversions/、discounts/、discount-codes/、bounties/、utm/、shopify/orders。新增资源时优先镜像这些已有目录的组织方式。Spec 模板一条测试的三段式结构技能文档给出了可直接复用的 Spec 骨架仓库中的 tags.spec.ts 就是它的忠实实现。核心模式是create → assert → cleanup(finally)import { expect } from playwright/test; import { randomName } from ../../utils; import { test, type ApiClient } from ../fixtures; async function createThing( api: ApiClient, overrides: Recordstring, unknown {}, ) { return api.postYourType(/api/things, { name: randomName(thing), ...overrides, }); } async function deleteThing(api: ApiClient, id: string | undefined) { if (!id) return; await api.delete(/api/things/${id}); } test(POST /things, async ({ api }) { let id: string | undefined; try { const body { name: randomName(thing) }; const { status, data } await api.postYourType(/api/things, body); id data.id; expect(status).toEqual(201); expect(data).toStrictEqual({ id: expect.any(String), ...body, // 来自 API 的稳定 null / 默认值 }); } finally { await deleteThing(api, id); } });要点拆解api.postT返回{ status, data }data已由 JSON 解析为T类型无需手动response.json()id声明在try之外finally中无条件清理即使断言失败也不会残留数据使用expect.any(String)匹配 id/时间戳等不稳定字段其余字段用toStrictEqual做全形状断言一旦定义了createThing后续的测试包括需要稳定 identity 字段的 follow-up 创建都应复用它通过overrides传参happy-path 测试也可以内联api.post以便断言体局部可见。以tags.spec.ts的POST /tags为例其响应断言为expect(tag).toStrictEqual({ id: expect.any(String), ...newTag, // { name, color } });color必须来自合法枚举非法值会返回unprocessable_entity错误信息明确列出可选值red, yellow, green, blue, purple, brown, gray, pink。必守约定速查表技能文档用一张表总结了所有强制约定逐条展开如下规则说明从../fixtures导入test提供api、workspace、program三个 fixture不要直接用playwright/test的test仅在共享状态时使用 serialapiproject 配置为fullyParallel: true见 playwright.config.ts不要手动加mode: parallel只有同一文件/describe 内共享状态如 domains、分页种子数据时才用test.describe.configure({ mode: serial })finally中清理创建 → 断言 → 必须删除创建的行唯一命名使用randomName/randomCustomer/randomPartnerEmail禁止写死易冲突的名称断言状态码 响应体优先toStrictEqual/toEqual全形状id/时间戳用expect.any(String)默认形状只断言一次happy-path POST 负责断言完整默认资源含嵌套形状变体测试只断言自己改变的部分只做 HTTP 契约断言断言状态码 JSON禁止 poll/sleep 等待waitUntil、R2 或其他后台任务——CI 没有STORAGE_*变量错误响应精确匹配必须精确匹配{ error: { code, message, doc_url } }结构泛型类型化一律api.getT、api.postT等使用种子 fixture用{ workspace }、{ program }含id、defaultGroupId、TEST_WORKSPACE不要用 Vitest 的E2E_*常量关于 serial 的取舍apiproject 在配置中fullyParallel: true即默认每个测试文件内并行。当测试之间确实共享可变状态例如 customers-pagination.spec.ts 在beforeAll中批量种入 25 条客户记录所有分页用例共享这批数据就必须用test.describe.configure({ mode: serial })声明否则并发运行会互相干扰。fixtures 源码解读worker 级作用域的秘密api/workspace/program三个 fixture 的实现见 fixtures.ts有几个关键设计值得学习apifixture 是worker 作用域{ scope: worker }原因在注释中写得很清楚beforeAll钩子里要能用api和program而 Playwright 不允许 test 级 fixture 出现在beforeAll中createApiClient基于APIRequestContext封装出get/post/patch/delete四个方法统一返回{ status, data }并自动附带Authorization: Bearer token与Content-Type: application/json请求头baseURL取自workerInfo.project.use.baseURL即http://localhost:8888见 constants.ts因此 Spec 中路径都是应用相对路径/api/...workspace.id/workspace.slug和program.id/program.defaultGroupId均从playwright/.auth/api.json读取确保与 globalSetup 种子完全一致。Helpers随机数据与排序断言共享工具集中在 utils.ts写作 Spec 前应优先复用而非重复造轮子randomName(prefix e2e, length 5)基于 nanoid 生成唯一名称避免跨测试碰撞randomCustomer()生成包含externalId、name、email、avatar: null、country的完整客户对象email 域默认dub-internal-test.comrandomPartnerEmail()生成唯一伙伴邮箱apiError({ code, message })按ErrorCodes映射出标准错误响应对象。错误码到 HTTP 状态的映射表定义在 error-codes.tsbad_request400、unauthorized401、forbidden403、not_found404、conflict409、unprocessable_entity422、rate_limit_exceeded429、internal_server_error500等doc_url自动指向https://dub.co/docs/api-reference/errors#codeexpectSortedById(items, order)/expectSortedByCreatedAt(items)对 id 做字典序比较、对createdAt做时间倒序比较expectNoOverlap(a, b)断言两组 id 集合无交集常用于验证 cursor 分页前后页不重叠。错误响应与表驱动用例错误契约是 API 测试的重头戏。技能文档给出的表驱动模式如下const errorCases [ { name: POST /things – missing name, body: {}, expected: { status: 422, data: { error: { code: unprocessable_entity, message: …, doc_url: https://dub.co/docs/api-reference/errors#unprocessable-entity, }, }, }, }, ]; for (const { name, body, expected } of errorCases) { test(name, async ({ api }) { expect(await api.post(/api/things, body)).toEqual(expected); }); }仓库实践推荐用apiError()helper 构造expected减少样板代码。tags.spec.ts中就有两个现成案例POST /tags传非法color→ 422unprocessable_entitymessage 明确列出合法颜色枚举POST /tags缺name→ 422unprocessable_entitymessage 为custom: name: Name is required.重复创建同名 tag → 409conflictmessage 为A tag with that name already exists.先用randomName创建成功再以同名请求验证冲突最后在finally中清理。如果响应的 payload 结构复杂还可以像 workspaces.spec.ts 那样用 Zod Schema 做运行时校验WorkspaceSchema.extend({ createdAt: z.string() }).parse(workspaceFetched)既验证了形状又验证了类型。注意这里的WorkspaceSchema来自 zod/schemas/workspaces真实路径为apps/web/lib/zod/schemas/workspaces.ts属于apps/web/lib/zod下的 API 响应 Schema。分页与边界条件customers-pagination 实战分页是最容易出边界 bug 的场景。customers-pagination.spec.ts 覆盖了以下契约startingAfter与endingBefore同时使用 → 422messageYou cannot use both startingAfter and endingBefore at the same time.page 1000MAX_OFFSET_PAGE→ 422message 提示“Page is too big … recommend using cursor-based pagination instead.”游标指向不存在的 idstartingAfter/endingBefore各测一次→ 422messageInvalid cursor: the provided ID does not exist.在beforeAll中通过 PrismacreateMany批量种入 25 条客户SEED_COUNT随后断言 offset 分页pagepageSize、cursor 分页startingAfter/endingBefore的正确顺序与页间无重叠expectNoOverlap并在afterAll中deleteMany清理。分页用例的种子方式值得注意Prisma 直接种子被允许用于批量 fixture因为 25 条记录走 HTTP POST 既慢又容易触发配额限制。但清理义务不变——必须在finally/afterAll中删干净。对于没有 DELETE 路由的资源如partners/partners.spec.ts中的部分场景使用 Prisma/conn直接清理同样是合法方案。从 Vitest API 测试迁移仓库正逐步把apps/web/tests/resource/*.test.ts中的 HTTP 用例迁移到 Playwright迁移步骤新建或扩展playwright/api/resource/resource.spec.ts迁移完成后不要保留并行的 Vitest HTTP Spec把IntegrationHarness/http.post({ path })映射为apifixture路径统一为/api/...把E2E_*/E2E_PARTNER_GROUP等常量替换为{ workspace }、{ program }与TEST_WORKSPACE确认 Playwright Spec 已覆盖对应用例后删除原 Vitest 文件。不要复制 Vitest 的IntegrationHarness或E2E_*常量进 Playwright Spec——两套体系在认证与种子方式上完全不同混用会引入隐性不一致。Do not 清单避免踩坑技能文档明确列出以下禁区不要在 Spec 中调用setupTestWorkspace只允许globalSetup调用不要在 API Spec 中引入浏览器page或 storage-state 认证那是 partners/workspaces project 的事不要提交密钥也不要改动固定的 Playwright token除非有意轮换本地/CI 测试认证不要跳过清理——并行 API project 中残留数据必然导致泄漏与偶发失败不要添加测试后执行pnpm build不要 poll 或setTimeout等待 R2/storage/waitUntil副作用CI 无STORAGE_*而是断言立即返回的 JSON 主体例如创建时外部imageURL 保持null就是正确断言点。如何运行先在 8888 端口起本地 dev server或依赖 CI 中webServer的pnpm start -p 8888然后# 运行全部 API 测试 pnpm --filter web test:e2e --projectapi # 运行单个资源文件 pnpm --filter web test:e2e --projectapi playwright/api/tags/tags.spec.ts # 按用例标题过滤正则可与文件路径叠加 pnpm --filter web test:e2e --projectapi playwright/api/tags/tags.spec.ts -g POST /tags-g/--grep匹配测试标题正则与文件路径组合可以精确圈定范围。在 CI 中playwright.config.ts 通过webServer自动启动pnpm start -p 8888120 秒超时并将stdout/stderr都设为ignore——因为 Zod 422 等预期内 API 错误会经handleApiError打到 stderr忽略它们可以让 Actions 日志保持可读。本地运行则无需webServer直接连接已启动的 dev server 即可。深入阅读playwright-api-tests 技能文档本文依据的原始规范playwright/README.md整个 e2e 测试体系的运行前提Chromium 安装、MailHog、环境变量api/fixtures.tsapi/workspace/programfixture 实现api/setup-test-workspace.ts种子数据与 token 写入逻辑api/tags/tags.spec.ts最简资源 Spec 范例api/customers/customers-pagination.spec.ts分页与边界契约范例utils.ts随机数据与断言 helperslib/api/error-codes.tsHTTP 状态码与错误码映射。掌握这套方法论后你可以为 Dub 的任何新/api/*资源快速落地一套结构一致、可并行、可清理、契约完备的 HTTP API 测试——这也是该仓库维护者在扩展 API 时实际遵循的标准路径。【免费下载链接】dubThe modern link attribution platform. Loved by world-class marketing teams like Framer, Perplexity, Superhuman, Twilio, Buffer and more.项目地址: https://gitcode.com/GitHub_Trending/du/dub创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考