Dify 前端测试策略解析:从 frontend-testing Skill 到 Vitest 双 Project 体系
本文以 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.mdis 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 给出了一条可执行的测试工作流(原文照录并展开):
- 识别可观察契约与回归风险(Identify the observable contract and regression risk)——先回答"这个行为对产品意味着什么,坏了用户会感知到什么";
- 选择包含行为所有者的最小边界(Choose the smallest boundary that includes the behavior owner);
- 在可行时先建立失败用例,再实现一个连贯场景(Establish the failing case first when practical, then implement one coherent scenario);
- 先跑聚焦的 spec,再跑受影响的套件和相关静态检查(Run the focused spec before the affected suite and relevant static checks);
- 汇报已验证的行为与残余风险(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、分支或文件存在;
- 组件能不崩溃地渲染;
- 实现中使用了
useState、useEffect、useMemo、useCallback; - 覆盖率报告显示某行未被覆盖;
- 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无法忠实表达浏览器行为时 → 使用browserproject; - Dify UI 原语遵循 Dify UI testing contract 中定义的 Storybook 与 Vitest 边界。
政策还特别强调:barrel 导出、透传包装器、纯展示型子组件不需要单独测试,只要拥有该行为的 feature 已证明其契约;也不要在 Dify 的集成、覆盖层与已知回归之外,重复验证已由 Base UI、React Aria 或浏览器自身拥有的通用行为。
Browser Mode 准入条件(重点)
这是政策中最具 Dify 特色的部分。happy-dom 是 web/ 下测试的默认选择,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 原文顺序):
getByRole+ 可访问名称;- 带 label 的表单控件用
getByLabelText; getByText、getByPlaceholderText或其他用户可见查询;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*,异步消失用waitForElementToBeRemoved或waitFor;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,并把每次调用记入unexpectedFetchCalls;afterEach中一旦发现未声明的 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 调用历史时在
beforeEach中vi.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 的完整配置为:
unit:environment: 'happy-dom'、globals: true、setupFiles: ['./vitest.setup.ts']、pool: 'threads',排除 browser spec 模式;browser:setupFiles: ['./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.ts 的 createReactI18nextMock()。这个 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/testing 的 NuqsTestingAdapter 提供三个入口: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-ui 的 vitest.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 的五步扩展为更完整的执行序列:
- 阅读行为所有者、其公共依赖与邻近测试;
- 在决定加测试前,先陈述契约与回归风险;
- 选择证明该契约的最小边界;
- 对行为变更或 bug 修复,在可行时先建立失败用例;
- 实现一个连贯场景,运行其聚焦 spec,在扩大范围前修好失败;
- 运行受影响的套件与相关仓库检查;
- 删除冗余断言、不必要的 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.ts、web/vitest.setup.ts、web/test/i18n-mock.ts、web/test/nuqs-testing.tsx 等基建文件证明这些政策不是纸面条款:fetch 守卫、Zustand 自动重置、i18n 稳定引用缓存、nuqs URL 断言助手,都已内建为框架层能力,供每个测试免费使用。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00