使用 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/dub
Dub 是一个现代化的短链与归因平台,其 API 层包含大量以工作区(workspace)为作用域的 Bearer 鉴权路由。本文基于仓库中的 playwright-api-tests 技能文档,系统讲解如何为 Dub 的/api/*路由编写、扩展和运行 Playwright HTTP API 测试:从种子数据与认证机制的底层实现、Spec 文件布局与必备约定,到错误契约、分页边界和 Vitest 迁移的完整实战流程。读完本文,你将掌握一套可直接复制、可重复运行、能在 CI 中稳定执行的 API 测试编写方法论。
为什么用 Playwright 而不是 Vitest 测 API
Dub 的测试体系按用途划分为三层,各有归属,不能混放:
| 测试类型 | 位置 | 技术栈 | 适用场景 |
|---|---|---|---|
| 单元 / 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 如何注入 token
API 测试的认证是一次性种入的,链路如下:
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-api@dub-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: Record<string, unknown> = {}, ) { return api.post<YourType>("/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.post<YourType>("/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.post<T>返回{ 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 |
| 仅在共享状态时使用 serial | apiproject 配置为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.get<T>、api.post<T>等 |
| 使用种子 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.com;randomPartnerEmail():生成唯一伙伴邮箱;apiError({ code, message }):按ErrorCodes映射出标准错误响应对象。错误码到 HTTP 状态的映射表定义在 error-codes.ts:bad_request=400、unauthorized=401、forbidden=403、not_found=404、conflict=409、unprocessable_entity=422、rate_limit_exceeded=429、internal_server_error=500等,doc_url自动指向https://dub.co/docs/api-reference/errors#<code>;expectSortedById(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_entity,message 明确列出合法颜色枚举;POST /tags缺name→ 422unprocessable_entity,message 为custom: name: Name is required.;- 重复创建同名 tag → 409
conflict,message 为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同时使用 → 422,message:You cannot use both startingAfter and endingBefore at the same time.;page > 1000(MAX_OFFSET_PAGE)→ 422,message 提示“Page is too big … recommend using cursor-based pagination instead.”;- 游标指向不存在的 id(
startingAfter/endingBefore各测一次)→ 422,message:Invalid cursor: the provided ID does not exist.; - 在
beforeAll中通过 PrismacreateMany批量种入 25 条客户(SEED_COUNT),随后断言 offset 分页(page+pageSize)、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 --project=api # 运行单个资源文件 pnpm --filter web test:e2e --project=api playwright/api/tags/tags.spec.ts # 按用例标题过滤(正则),可与文件路径叠加 pnpm --filter web test:e2e --project=api playwright/api/tags/tags.spec.ts -g "POST /tags"-g/--grep匹配测试标题(正则),与文件路径组合可以精确圈定范围。在 CI 中,playwright.config.ts 通过webServer自动启动pnpm start -p 8888(120 秒超时),并将stdout/stderr都设为ignore——因为 Zod 422 等预期内 API 错误会经handleApiError打到 stderr,忽略它们可以让 Actions 日志保持可读。本地运行则无需webServer,直接连接已启动的 dev server 即可。
深入阅读
- playwright-api-tests 技能文档:本文依据的原始规范;
- playwright/README.md:整个 e2e 测试体系的运行前提(Chromium 安装、MailHog、环境变量);
- api/fixtures.ts:
api/workspace/programfixture 实现; - api/setup-test-workspace.ts:种子数据与 token 写入逻辑;
- api/tags/tags.spec.ts:最简资源 Spec 范例;
- api/customers/customers-pagination.spec.ts:分页与边界契约范例;
- utils.ts:随机数据与断言 helpers;
- lib/api/error-codes.ts:HTTP 状态码与错误码映射。
掌握这套方法论后,你可以为 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),仅供参考