news 2026/9/11 22:41:53

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
使用 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/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/partnersplaywright/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,字段包括tokenworkspaceIdworkspaceSlugprogramIddefaultGroupIdbaseURL

整套逻辑使用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.tsPOST /tags为例,其响应断言为:

expect(tag).toStrictEqual({ id: expect.any(String), ...newTag, // { name, color } });

color必须来自合法枚举,非法值会返回unprocessable_entity,错误信息明确列出可选值:red, yellow, green, blue, purple, brown, gray, pink

必守约定速查表

技能文档用一张表总结了所有强制约定,逐条展开如下:

规则说明
../fixtures导入test提供apiworkspaceprogram三个 fixture,不要直接用@playwright/testtest
仅在共享状态时使用 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.get<T>api.post<T>
使用种子 fixture{ workspace }{ program }(含iddefaultGroupId)、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钩子里要能用apiprogram,而 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.slugprogram.id/program.defaultGroupId均从playwright/.auth/api.json读取,确保与 globalSetup 种子完全一致。

Helpers:随机数据与排序断言

共享工具集中在 utils.ts,写作 Spec 前应优先复用而非重复造轮子:

  • randomName(prefix = "e2e", length = 5):基于 nanoid 生成唯一名称,避免跨测试碰撞;
  • randomCustomer():生成包含externalIdnameemailavatar: nullcountry的完整客户对象,email 域默认dub-internal-test.com
  • randomPartnerEmail():生成唯一伙伴邮箱;
  • apiError({ code, message }):按ErrorCodes映射出标准错误响应对象。错误码到 HTTP 状态的映射表定义在 error-codes.ts:bad_request=400unauthorized=401forbidden=403not_found=404conflict=409unprocessable_entity=422rate_limit_exceeded=429internal_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 /tagsname→ 422unprocessable_entity,message 为custom: name: Name is required.
  • 重复创建同名 tag → 409conflict,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 覆盖了以下契约:

  • startingAfterendingBefore同时使用 → 422,message:You cannot use both startingAfter and endingBefore at the same time.
  • page > 1000MAX_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),并在afterAlldeleteMany清理。

分页用例的种子方式值得注意:Prisma 直接种子被允许用于批量 fixture,因为 25 条记录走 HTTP POST 既慢又容易触发配额限制。但清理义务不变——必须在finally/afterAll中删干净。对于没有 DELETE 路由的资源(如partners/partners.spec.ts中的部分场景),使用 Prisma/conn直接清理同样是合法方案。

从 Vitest API 测试迁移

仓库正逐步把apps/web/tests/<resource>/*.test.ts中的 HTTP 用例迁移到 Playwright,迁移步骤:

  1. 新建或扩展playwright/api/<resource>/<resource>.spec.ts,迁移完成后不要保留并行的 Vitest HTTP Spec;
  2. IntegrationHarness/http.post({ path })映射为apifixture(路径统一为/api/...);
  3. E2E_*/E2E_PARTNER_GROUP等常量替换为{ workspace }{ program }TEST_WORKSPACE
  4. 确认 Playwright Spec 已覆盖对应用例后,删除原 Vitest 文件。

不要复制 Vitest 的IntegrationHarnessE2E_*常量进 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 中webServerpnpm 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),仅供参考

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

【计算机毕业设计单片机案例】基于 STM32 的人机多交互模式 LED 智能调光系统设计 基于 STM32 的环境光与人存在感知智能照明硬件设计(023607)

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

作者头像 李华
网站建设 2026/9/11 22:41:44

微信小程序来访预约审批系统源码解析:从表单到二维码生成

简介&#xff1a;面向小程序开发与毕业设计场景的来访预约审批系统完整源码及文档说明&#xff0c;属于高分项目&#xff0c;评审分98分&#xff0c;适合计算机相关专业正在做期末大作业、毕业设计&#xff0c;或需要项目实战练习的学习者。资源共480个文件&#xff0c;包含183…

作者头像 李华
网站建设 2026/9/11 22:41:25

C51单片机控制ISD1820PY语音录放模块:原理图、接线与代码详解

简介&#xff1a;面向电子爱好者和嵌入式开发者&#xff0c;这份基于ISD1820PY芯片的10秒录音器模块开发包&#xff0c;提供原理图、PCB设计、C51单片机控制源码及说明文档&#xff0c;覆盖语音玩具、电子贺卡等简单录放音场景的完整软硬件方案。ISD1820PY支持单次、循环及地址…

作者头像 李华
网站建设 2026/9/11 22:41:10

SoybeanAdmin Vue3 管理后台模板:克隆到跑起来只需 3 条命令

SoybeanAdmin Vue3 管理后台模板&#xff1a;克隆到跑起来只需 3 条命令 【免费下载链接】soybean-admin A clean, elegant, beautiful and powerful admin template, based on Vue3, Vite7, TypeScript, Pinia, NaiveUI and UnoCSS. 一个清新优雅、高颜值且功能强大的后台管理…

作者头像 李华
网站建设 2026/9/11 22:39:06

YOLOv8实战:翻越栏杆检测数据集训练与VOC转YOLO全攻略

简介&#xff1a;面向翻越栏杆/围栏检测场景的专用目标检测数据集&#xff0c;由真实场景图片组成&#xff0c;共1680张JPG照片&#xff0c;每张均配套Pascal VOC XML与YOLO TXT两种主流标注格式&#xff0c;可直接替换进现有YOLO、Faster R-CNN、SSD等检测训练流程&#xff0c…

作者头像 李华
网站建设 2026/9/11 22:38:39

用户成长体系设计:闯关进度系统的架构与优化

1. 项目背景与核心价值 "youyu001闯关进度"这个看似简单的标题背后&#xff0c;隐藏着一个典型的用户成长体系设计需求。在当今各类互联网产品中&#xff0c;无论是教育平台、游戏应用还是工具类软件&#xff0c;闯关进度机制都已成为提升用户粘性和活跃度的标配功能…

作者头像 李华