首页
/ Dify 前端测试策略解析:从 frontend-testing Skill 到 Vitest 双 Project 体系

Dify 前端测试策略解析:从 frontend-testing Skill 到 Vitest 双 Project 体系

2026-09-06 11:10:30作者:郜逊炳

本文以 Dify 仓库中的 frontend-testing Skill 为主线,完整拆解 Dify 前端测试方法论:何时该写测试、如何选择最小测试边界、Browser Mode 的准入条件、Mock 应该放在哪里,以及 unit(happy-dom)与 browser(Playwright Chromium)两个测试 Project 的具体配置与配套基建(i18n mock、zustand 自动重置、fetch 守卫等)。读完本文,你可以在 Dify 代码库中按官方政策正确地编写、运行和评审前端测试。

一、文档定位:Skill 是入口,政策只有一个所有者

.agents/skills/frontend-testing/SKILL.md 是一份面向 Agent 的技能说明,其 description 明确界定了适用场景:

  • 适用:在 web/packages/dify-ui/ 下编写或修改 Vitest、React Testing Library 测试,或用户明确要求前端测试策略评估(包括评估既有策略);
  • 不适用:仅做前端代码评审、泛泛讨论可测试性、Python 测试,以及 Cucumber/Playwright E2E 场景。

该 Skill 的第一条原则是政策归属声明:

web/docs/test.md is the single policy owner. Read it before changing frontend tests; this skill adds no separate requirements.

web/docs/test.md 是前端自动化测试的唯一政策所有者(single source of truth),Skill 本身不新增任何要求;而 packages/dify-ui/docs/testing.md 则拥有 Dify UI 组件库的包级测试契约。下文将完整继承这两份政策文档的核心内容,并结合仓库源码逐条印证其落地方式。

二、Skill 的五步工作法

SKILL.md 给出了一条可执行的测试工作流(原文照录并展开):

  1. 识别可观察契约与回归风险(Identify the observable contract and regression risk)——先回答"这个行为对产品意味着什么,坏了用户会感知到什么";
  2. 选择包含行为所有者的最小边界(Choose the smallest boundary that includes the behavior owner);
  3. 在可行时先建立失败用例,再实现一个连贯场景(Establish the failing case first when practical, then implement one coherent scenario);
  4. 先跑聚焦的 spec,再跑受影响的套件和相关静态检查(Run the focused spec before the affected suite and relevant static checks);
  5. 汇报已验证的行为与残余风险(Report the behavior verified and any remaining browser, visual, or end-to-end risk)——明确声明哪些风险(浏览器、视觉、端到端)仍不在本次测试的覆盖范围内。

Skill 的结尾还有一条与"只增不减"相反的价值观:Recommend deleting low-value tests as readily as adding missing behavior coverage——推荐删除低价值测试的爽快程度,应当与补充缺失行为覆盖的爽快程度相同。这一条与 web/docs/test.md 中"测试不是逐文件的覆盖率练习(not a file-by-file completion exercise)"的立场完全一致。

三、测试心智模型:什么时候该写、什么时候不该写

web/docs/test.md 的 Testing Mindset 一节是整篇政策的判断标准。当变更影响一个稳定的、可观察的契约时,才应编写或更新测试:

  • 用户交互及其引发的 UI 状态变化;
  • 导航、URL 状态、持久化、权限或数据流;
  • 用户实际可达的加载、成功、错误、空态;
  • 可访问性语义、键盘行为、焦点管理、禁用状态;
  • 具有有意义输入/输出行为的业务逻辑或可复用工具函数;
  • 能通过公共边界复现回归的 bug 修复。

同时明确列出了不应因此添加测试的情形:

  • 仅仅因为某个组件、hook、prop、分支或文件存在;
  • 组件能不崩溃地渲染;
  • 实现中使用了 useStateuseEffectuseMemouseCallback
  • 覆盖率报告显示某行未被覆盖;
  • TypeScript 类型已经让某类输入不可能出现;
  • 变更只调整类名、间距、颜色或响应式布局而不改变行为。

对于纯视觉变更,政策要求在代表性宽度和状态下人工验证真实 UI,"当风险值得自动化时"再使用浏览器、截图、Storybook 或端到端覆盖。

覆盖率:诊断信号而非质量目标

政策明确:覆盖率是诊断信号(diagnostic signal),不是质量目标。文档不定义任何强制百分比,评审者不应仅为提高覆盖率而索要测试。正确用法是借助报告发现可疑缺口,再逐个判断该缺口是否代表值得保护的产品风险。这一立场在 web/vite.config.ts 中也有对应配置:覆盖率 provider 为 v8,CI 下只输出 json/json-summary 报告(非 CI 下另有 text 报告),且排除 **/__mocks__/** 目录(第 83-87 行)。

四、选择正确的测试边界

政策要求"使用包含行为所有者、且能证明产品契约而不与实现耦合的最小边界",并给出分层指引:

  • 纯转换和业务规则 → 单元测试;
  • Hook 本身暴露可复用公共契约时才直接测 hook,否则通过其所属组件或 feature 来执行;
  • 可通过 DOM 或外部副作用观察的组件与 feature 行为 → React Testing Library;
  • 跨越有意义模块边界的行为 → 集成测试;
  • 只有 happy-dom 无法忠实表达浏览器行为时 → 使用 browser project;
  • Dify UI 原语遵循 Dify UI testing contract 中定义的 Storybook 与 Vitest 边界。

政策还特别强调:barrel 导出、透传包装器、纯展示型子组件不需要单独测试,只要拥有该行为的 feature 已证明其契约;也不要在 Dify 的集成、覆盖层与已知回归之外,重复验证已由 Base UI、React Aria 或浏览器自身拥有的通用行为。

Browser Mode 准入条件(重点)

这是政策中最具 Dify 特色的部分。happy-domweb/ 下测试的默认选择,unit project 用于纯逻辑、hook、以及不依赖浏览器独有行为的 DOM 可观察组件行为。关键原则是:先按行为所有者选择测试范围,再按证明契约所需的环境选择 project——而不是按"focus"、"keyboard"、"pointer"这类交互标签选。例如 user.tab() 可以保护由简单语义标记编码的焦点序列(断言由 DOM 顺序、禁用状态和 tabindex 决定),但它并不验证浏览器原生的顺序焦点导航。

只有能具体说出一个 happy-dom 会漏掉的浏览器独有失败时,才使用 browser project,例如:

  • CSS 布局或渲染可见性改变了几何、命中测试、响应式行为或指针指向;
  • 浏览器计算的焦点资格、inert、Shadow DOM 遍历等导致原生焦点行为或焦点事件顺序变化;
  • 选择、滚动、真实键盘/指针输入、浏览器 API、观察者或动画生命周期的原生实现改变了结果。

政策同时给出负面清单:portal、focus trap、shadow root、observer、焦点断言或键盘/指针交互的存在本身不构成使用 Browser Mode 的理由;"渲染 UI、减少 mock、提高信心、提高覆盖率"也不够。每一个 web/app/ 下的 *.browser.spec.{ts,tsx} 测试都必须用最小所有者 + 语义定位器完成,并用浏览器独有契约为额外的运行时成本辩护;禁止强制交互、固定 sleep、私有 DOM/CSS 断言和真实网络请求。且 Browser Mode 依然是聚焦的组件或 feature 测试,目前只证明 Chromium;运行中的应用、认证、真实路由、后端 API、持久化或完整旅程应交给端到端套件。

这些准入规则与 web/vite.config.ts 的实际配置一一对应:unit project 使用 pool: 'threads' + environment: 'happy-dom',并显式排除 app/**/*.browser.spec.{ts,tsx}browser project 恰好 include 该模式,启用 browser.enabled + playwright() provider + chromium 实例,headless: true,失败时截图与 trace 保留在 web/.vitest-browser/ 目录(第 88-131 行)。

五、断言行为,而非实现

政策的断言原则:

  • 通过 props、用户交互、URL 变化或公共 API 驱动状态转换;
  • 断言渲染后的 UI、ARIA 状态、导航、持久化、网络边界调用或其他可观察结果;
  • 针对隐藏表面的"重置或持久化"回归,要走公共转换路径:打开、修改、关闭并等待表面消失、再重新打开——不要耦合 hook 位置、组件名、key 或私有挂载结构;
  • 不检查 React 状态、refs、hook 调用顺序、effect 依赖或私有 DOM 结构;
  • 只有当引用相等(referential identity)本身是已文档化的公共契约时才测它;
  • 一个测试描述一个行为,可以包含共同证明该行为的多个断言;
  • 只测类型与产品契约支持的输入状态,不人为制造 null/undefined/极端值;
  • 除非序列化输出或类名契约是刻意公开且稳定的,避免快照和 CSS 类断言。

六、查询、交互与可访问性

查询优先级(policy 原文顺序):

  1. getByRole + 可访问名称;
  2. 带 label 的表单控件用 getByLabelText
  3. getByTextgetByPlaceholderText 或其他用户可见查询;
  4. getByTestId 仅用于确实没有有用 DOM 语义的边界(canvas 输出、编辑器 shim、被 mock 的非可视化集成)。

重复内容产生歧义时,先收窄到语义容器,再用 React Testing Library 的 within 或 Browser Mode 的定位器链查询。若交互控件无法语义化定位,先检查生产标记是否缺少真实的 button、link、label、landmark 或可访问名称。

交互与断言约定:

  • React Testing Library 中在测试内使用 userEvent.setup() 实例;仅当低级事件本身就是契约时才用 fireEvent
  • Browser Mode 中通过 await 定位器交互,仅对定位器不暴露的 DOM API 使用 .element()
  • 键盘与焦点行为属于交互契约时,必须测试;
  • 同步缺失用 queryBy*,异步出现用 findBy*,异步消失用 waitForElementToBeRemovedwaitFor;Browser Mode 用 expect.element 做最终断言;
  • 精确文案断言在"文案或翻译 key 本身即契约"时有效,否则优先语义查询或稳健匹配;
  • 语义查询与自动检查不构成完整的可访问性合规。

七、在真实边界上 Mock

原则:保持拥有/转换被断言行为的生产代码为真实代码,只 mock 目标契约之外的依赖:

  • 服务与网络边界;
  • Next.js 导航或测试环境未提供的浏览器 API;
  • 外部 SDK 与昂贵 provider;
  • 不拥有被断言行为、且其 setup 会主导所有者测试的、已被独立测试的子边界。

并且:不要 mock Dify UI 交互原语或其 feature 包装器——保持其语义角色、状态属性、portal、焦点行为与 render(props, state) 契约为真实,只 mock 触达场景所需的服务或外部数据边界。其余硬性规则:永不发真实网络请求;共享 mock 状态在被其修改的每个测试前重置;测 query 行为时创建全新的 TanStack Query client;复杂数据优先用带有效默认值的类型化 builder;mock 保持局部,只有多个套件真正共享时才移入 web/__mocks__/

这些规则在仓库中有直接的代码对应:

  • web/vitest.setup.ts 全局替换 globalThis.fetch 为一个永不 resolve 的 mock,并把每次调用记入 unexpectedFetchCallsafterEach 中一旦发现未声明的 fetch 调用即 throw new Error('Unexpected fetch request(s): ...')(第 16-21、31-36、145-149 行)——这就是"Never make real network requests"在框架层的强制实现;
  • 同文件用 vi.mock('zustand') 配合 web/mocks/zustand.ts 实现"每个测试后自动重置所有 Zustand store",beforeEach 中还会清空 localStorage
  • 共享 mock 目录 web/mocks/ 目前只含 TanStack Query、provider-context、zustand 等真正跨套件共享的 mock,印证了"mock 保持局部"的纪律。

八、异步、时间与隔离

  • 必须 await 用户交互、promise、findBy*waitFor
  • 等待可观察状态变化,禁止用固定 sleep 或宽泛重试掩盖时序错误;
  • findBy* 用于异步出现的元素,waitFor 用于"最终为真"的外部断言;
  • 仅当定时器行为本身是契约的一部分时才用 fake timers,且测试后恢复真实计时器;
  • 控制时间、随机性、网络响应与共享 store,保证测试确定性;
  • 套件依赖 mock 调用历史时在 beforeEachvi.clearAllMocks(),不要用 afterEach 为下一个测试做铺垫。

政策同时声明 web/vitest.setup.ts 已经替所有测试做了 Testing Library 的 cleanup()(并包在 act() 中以避免 React 19 调度器在 DOM 清理后触发 window is not defined 错误,见 web/vitest.setup.ts 第 23-37 行)和 Zustand store 重置,测试作者无需重复实现。

九、Dify 测试基建详解

双 Project 配置

web/docs/test.md 声明:web/ 下测试使用 web/vite.config.ts 中两个显式 project,命令和 CI 总是显式选择其一。源码中两个 project 的完整配置为:

  • unitenvironment: 'happy-dom'globals: truesetupFiles: ['./vitest.setup.ts']pool: 'threads',排除 browser spec 模式;
  • browsersetupFiles: ['./vitest.browser.setup.ts']include: ['app/**/*.browser.spec.{ts,tsx}'],Playwright provider、单 Chromium 实例、headless,screenshotFailures: true 截图到 .vitest-browser/screenshots,trace 以 retain-on-failure 模式保存在 .vitest-browser/traces

web/vitest.browser.setup.ts 的内容也值得注意:加载真实全局样式(app/styles/globals.css)、设置 light 主题、同样设置 BASE_UI_ANIMATIONS_DISABLED = true 并挂载 i18n mock——保证 Browser Mode 下的组件处于接近生产的外观与行为。

共享 i18n mock

两个 setup 文件都通过 vi.mock('react-i18next', ...) 全局加载 web/test/i18n-mock.tscreateReactI18nextMock()。这个 mock 并非简单的"返回 key":它支持 {{param}} 插值、ns:key 命名空间解析、selector 形式的 i18nKey(通过 Proxy 追踪选择路径),且 t 函数按默认命名空间缓存——源码注释说明这是为了同一引用跨 render 稳定,防止组件把 t 放进 useEffect/useMemo 依赖数组时引发无限重渲染循环。政策文档的对应规则是:仅当测试需要自定义翻译时,才在单个测试里用 createReactI18nextMock({ 'operation.confirm': 'Confirm' }) 覆盖。

nuqs(URL 状态)测试助手

政策声明 nuqs 行为应使用 web/test/nuqs-testing.tsx 的助手并断言 URL 更新,只有当 URL 同步明确不在测试契约内时才 mock nuqs。该文件基于 nuqs/adapters/testingNuqsTestingAdapter 提供三个入口:createNuqsTestWrapper(返回 wrapper 与 onUrlUpdate spy)、renderWithNuqs(render + spy)、renderHookWithNuqs(renderHook + 初始 props + spy),让测试既能喂入初始 searchParams,又能精确断言 URL 变更事件。

文件组织与工具链约束

  • 新的组件与 feature spec 一般使用同级 __tests__/ 目录;既有的内联工具/hook spec 可沿用所属模块约定;跨 feature 的集成 spec 放在 web/__tests__/
  • 除非有已证明的项目级需求,不得再引入第二个测试 runner、DOM 环境或网络拦截库。

web/package.json"test" 脚本即为 vp test --project unit,与政策"总是显式传 --project unit--project browser;裸 vp test 会跑两个已注册 project,不是标准 Web 测试命令"保持一致。

十、Dify UI 组件库的测试契约

packages/dify-ui/docs/testing.md 定义了这个包的独立边界。命令方面:仓库根目录执行 vp check packages/dify-ui(格式、lint、TypeScript 诊断),包内执行:

  • vp test --project unit:原语单元测试;
  • vp run storybook:启动 Storybook;
  • vp test --project storybook --run:Browser Mode 下运行 Storybook 组件测试;
  • vp test:同时运行两个测试 project。

该包有两个 Vitest project,运行在 Playwright Chromium Browser Mode 中;project 名区分的是行为所有者,不是运行时差异。分工规则:

  • Storybook 承载已文档化的组件示例——每个 story 都是一个渲染契约,并通过 Storybook Vitest addon 运行配置的 a11y 检查;当示例还拥有可见状态变化、用户交互、键盘路径、覆盖层流程、表单行为、加载行为或受控状态协同时,添加 play 函数;
  • 普通 Vitest 测试 用于更底层的包装器契约:类名变体、Base UI 透传 props、隐藏输入序列化、data-attribute 钩子、store,以及不需要文档化示例的边缘情况。

可访问性策略:Storybook a11y 配置为 a11y.test = 'error',启用的违规即失败;颜色对比是全局唯一禁用的规则(已知 design-token 缺口),不得新增全局豁免;临时豁免必须局部化到受影响 story,且不得用 play 测试替代 a11y 修复。

Base UI 动画开关

Base UI 可能在卸载过渡组件前等待 element.getAnimations()。当测试断言的是最终 DOM 状态而非动画行为时,应在 Vitest setup 文件中设置测试开关:

;(
  globalThis as typeof globalThis & {
    BASE_UI_ANIMATIONS_DISABLED: boolean
  }
).BASE_UI_ANIMATIONS_DISABLED = true

packages/dify-uivitest.setup.ts 已为原语测试应用该设置;Storybook 使用其 preview setup 并保留真实动画生命周期(因为它要测交互流程);故意断言动画行为的单元测试可局部恢复为 false,但必须在 cleanup 中还原原值。web/ 侧的 web/vitest.setup.ts(第 7-14 行)同样设置了该标志,并为 happy-dom 补齐了缺失的 Element.prototype.getAnimations

十一、命令速查

以下命令从 web/ 目录执行(原文照录自 web/docs/test.md Commands 一节):

# happy-dom;省略路径则运行完整 unit project
vp test run --project unit path/to/spec-or-directory

# Browser Mode;省略路径则运行完整 browser project
vp test run --project browser path/to/spec.browser.spec.tsx

# Watch 模式;Browser Mode 请改选 browser
vp test watch --project unit path/to/spec

# unit project 的诊断性覆盖率报告;不是验收目标
vp test run --project unit --coverage path/to/spec-or-directory

Browser 失败时,截图与 Playwright trace 保留在 web/.vitest-browser/;CI 仅在该目录存在失败产物时上传它,且 Browser Mode 不承担覆盖率或报告合并。

十二、评审清单

政策文末的 Review Checklist 是评审前端测试 PR 时的核对表:

  • 每个测试是否保护一个可达的产品契约或值得保护的回归?
  • 行为是否通过公共边界被执行?
  • 相关处是否使用了语义查询与可访问性契约?
  • Mock 是否位于有意的边界,且对这些边界足够忠实?
  • 套件是否确定、聚焦、且维护成本低于它所预防的回归?
  • 该测试能否在一个"保持行为不变的 refactor"中存活?
  • 评审者能否说出一条现实的回归及其会失败的断言?
  • 对 Browser Mode:浏览器独有契约是否明确、是否在 happy-dom 中无法忠实证明、是否值得额外运行时?

十三、工作流(政策视角)

web/docs/test.md 的 Workflow 一节把 Skill 的五步扩展为更完整的执行序列:

  1. 阅读行为所有者、其公共依赖与邻近测试;
  2. 在决定加测试前,先陈述契约与回归风险;
  3. 选择证明该契约的最小边界;
  4. 对行为变更或 bug 修复,在可行时先建立失败用例;
  5. 实现一个连贯场景,运行其聚焦 spec,在扩大范围前修好失败;
  6. 运行受影响的套件与相关仓库检查;
  7. 删除冗余断言、不必要的 mock 和只是镜像实现的测试。

跨多文件工作时,按依赖顺序组织工作,每验证一个连贯切片再前进;不要默认按"每个源文件一个测试文件"组织

总结

Dify 的前端测试体系可以概括为三层:frontend-testing Skill 提供五步执行入口与"删测试和加测试一样果断"的价值观;web/docs/test.md 作为唯一政策所有者,定义了"何时写、选什么边界、断言什么、在哪 mock、怎么跑"的完整规则,并把 Browser Mode 的准入门槛抬到"必须能命名一个 happy-dom 证明不了的浏览器独有失败";packages/dify-ui/docs/testing.md 则为组件库划定了 Storybook(渲染契约 + a11y)与单元测试(包装器契约)的边界。而 web/vite.config.tsweb/vitest.setup.tsweb/test/i18n-mock.tsweb/test/nuqs-testing.tsx 等基建文件证明这些政策不是纸面条款:fetch 守卫、Zustand 自动重置、i18n 稳定引用缓存、nuqs URL 断言助手,都已内建为框架层能力,供每个测试免费使用。

登录后查看全文
热门项目推荐
相关项目推荐