Dify 的 E2E 测试方法论:Cucumber + Playwright 协作规范与落地实践
Dify 的 E2E 测试套件(e2e/)采用 Cucumber 负责场景编排与生命周期管理、Playwright 作为浏览器自动化层的双框架架构。本文基于仓库中的技能规范 e2e-cucumber-playwright/SKILL.md 及其两份配套参考文档展开,讲清楚这套 E2E 体系的"谁管什么、何时读哪份参考、写作与审查遵循什么原则",并结合 e2e/ 源码包中的 World、hooks、Cucumber 配置与真实 feature 文件,展示这些规范如何在 Dify 中落地为可执行的工程约束。读完后你将掌握:如何判断一条用户旅程是否值得写 E2E 覆盖、如何编写可执行规格的 Gherkin 场景、如何在 Cucumber/Playwright 双框架间保持职责边界,以及如何在代码审查中定位 flake 与架构漂移。
1. 技能定位:明确职责边界,不引入平行规范
技能文档开篇就界定了三者的分工,这是理解整个体系的前提:
e2e/AGENTS.md拥有套件级契约:套件架构、生命周期、命令、标签、生成客户端边界、fixtures 与清理契约都由 e2e/AGENTS.md 负责;某个 feature 下如果存在更近的AGENTS.md,则以那个为 feature 级事实来源。该技能"不添加任何平行的包级策略",也就是说它管的是写作与审查方法论,而不是套件架构。- Cucumber 拥有场景与 hook 执行及报告:Gherkin 场景、Before/After hooks、报告输出由 Cucumber 负责。
- Playwright 提供浏览器自动化能力:browser context、pages、locators、actions、assertions、request、tracing 等 API 均来自 Playwright;协议层异常(protocol exceptions)仍归其包定义的属主处理。
一条关键警告值得单独强调:两者的超时域是相互独立的。从源码结构看,cucumber.config.ts 中配置了 timeout: 60_000,这是 Cucumber 步骤与 hook 层的预算;而 Playwright 的 locator/action 超时、断言默认超时(5 秒)是另一套预算。技能文档明确指出:从 @playwright/test 导入 API 不会让 Playwright Test runner 的配置(projects、workers、retries、fixtures、reporters)适用于本套件——尽管 e2e/package.json 中确实依赖了 @playwright/test,但这里只把它当作浏览器库(chromium/webkit 引擎、APIRequestContext、断言库)使用,而非 runner。
这一点对实践影响很大:不要试图用 @playwright/test 的 test.describe、retry 机制或 trace: 'on-first-retry' 来"升级"这套 Cucumber 驱动的套件,那些配置在本 harness 中不生效。
2. 主题路由:按变更类型选择最小参考集
技能采用"按需读取"的参考路由,避免在每次变更时通读全部规范:
| 变更类型 | 应读取的参考文档 |
|---|---|
| Locator、断言、隔离、等待决策 | references/playwright-best-practices.md |
| 场景措辞、步骤粒度、表达式、World 状态、hook 可见性、标签设计 | references/cucumber-best-practices.md |
两条参考文档都列出了官方文档源(cucumber.io 的 Better Gherkin / Gherkin Reference / Cucumber Expressions / World 文档,以及 Playwright 的 Best Practices / Locators / Actionability / Test Assertions / Timeouts / Browser Contexts / Trace Viewer),并要求:
引入任何本地代码与参考都尚未确立的框架模式前,先查阅当前官方的 Playwright 或 Cucumber 文档。
这是对"随手引入新范式"的工程约束:模式先要有先例,再对照官方文档确认。
3. 工作流:从"该不该写"到"怎么验证"
技能定义了一条 6 步工作流,覆盖 E2E 用例从立项到验证的完整决策链:
- 只为跨边界关键用户旅程加 E2E 覆盖。判据是:这条旅程的结果(outcome)跨越边界,且更便宜的属主级测试(owner-level tests,如组件/服务单测)已经无法证明它。E2E 是最贵的测试层,默认不该写。
- 先识别用户可见行为及其 feature 属主。从真实产品默认值和 actor 角色出发;setup 可以建立前置条件,但不得人为制造相反状态来让场景显得有意义(例如故意把默认开启的功能关掉再测"开启它")。
- 按需读取文件:目标场景、匹配的 step definitions;只有当会话或共享状态确实相关时,才读取生命周期文件(hooks)。
- 步骤复用优先:措辞与行为都匹配时复用已有 step;不匹配时新增一个连贯的场景或步骤,而不是写一个含义模糊的"通用"步骤。
- 边界原则:浏览器操作与断言放在公开用户边界(public user boundary);setup、seed、轮询、cleanup 放在各自包定义的属主处。
- 变更验证范围最小化:改动后运行 e2e/AGENTS.md 中记录的最窄标签场景与包级检查;只有涉及共享 hooks、标签或 support 代码时才扩大验证范围。
对应到实际运行方式,最窄验证通常就是一条标签命令,例如:
# 只跑 @smoke 标签的场景
pnpm -C e2e e2e -- --tags @smoke
标签命令的完整矩阵(e2e、e2e:full、e2e:prepared、e2e:external、e2e:headed、无障碍扫描 e2e:accessibility:a / e2e:accessibility:aa、e2e:reset、e2e:middleware:up/down 等)由 e2e/AGENTS.md 的 Commands 章节统一拥有,这里不重复。
3.1 审查(Review)请求的应答规范
对工作流第 6 步之外的另一类请求——review——技能给出了明确的输出格式要求:
- 以可复现的正确性失败、flake 来源、或可证实的架构漂移开场(lead with reproducible correctness failures, flake sources, or demonstrated architecture drift);
- 报告已验证的行为,以及任何外部运行时、浏览器或环境方面的缺口(gap)。
这意味着 review 结论必须可复现、可定位,而不是泛泛的"建议优化"。
4. Cucumber 侧实践:可执行规格六原则
references/cucumber-best-practices.md 是场景与步骤层的规范核心,其"What Matters Most"归纳为六点。
4.1 场景是可执行规格(executable specifications)
场景应当声明式地描述行为,而不是复述交互脚本:
- 写"用户做了什么、应该发生什么";
- 避免 UI 内部措辞:选择器细节、DOM 结构、组件名不应出现在 Gherkin 里;
- 语言要具体到可以当作活文档(living documentation)阅读。
例外:当交互机制本身就是被测行为时(键盘导航、焦点移动、必须的多 actor 时序),过程性细节是合适的。
一个真实的正面示例来自 e2e/features/smoke/authenticated-entry.feature:
@smoke @authenticated
Feature: Authenticated console home
Scenario: Open the default console entry with the shared authenticated state
Given I am signed in as the default E2E admin
When I open the default console entry
Then I should be on the console home
And I should not see the "Sign in" button
整个场景没有一处提及路由路径、CSS 选择器或按钮的 DOM 结构,读起来就是产品行为的真实例子。
4.2 场景保持聚焦
- 一个场景证明一个业务规则或一个连贯结果;
- Cucumber 官方"三到五步"的建议是审查启发式而非硬限制:场景偏长时,应检查是否存在多个结果、附带性 setup、或可以下沉到领域步骤后面的 UI 流程;
Given用于初始上下文、When用于事件、Then用于期望结果;阶段"回环"是重新审视叙事的信号,而不是自动失败;- 避免对其它场景副作用的隐藏依赖;
- 多个例子说明同一条具名业务规则时用
Rule;Background只用于读者理解所必需的共享上下文,不要把 fixture 准备、运行时就绪或长 setup 藏进Background。
4.3 步骤复用:只在行为真正一致时
复用的判据是四条同时成立:
- 用户动作确实相同;
- 期望结果确实相同;
- 措辞在不同 feature 中保持自然;
- 参数是真实的产品领域值(具名表面、模式、资源或状态)。
行为实质不同、复用旧措辞会产生误导、或"通用步骤"退化为实现细节包装器时,应新写步骤。原文总结得很到位:"不要通过制造模糊步骤来优化步骤数量,要优化为一组真实、领域拥有的小步骤集。"
一条实现层面的约束同样重要:step definitions 共享一个全局匹配命名空间,与所在目录无关。因此按领域能力组织(capability-oriented),避免 feature 耦合的 glue 或宽到能与不相关行为重叠的表达式。这在 Dify 的目录结构中得到体现:e2e/features/step-definitions/ 下按 accessibility、agent-v2、apps、auth、common 划分,common/ 只留给真正跨能力的步骤(如 auth.steps.ts 中的 I am signed in ... 步骤被 smoke、auth 等多个 feature 目录复用)。
4.4 优先 Cucumber Expressions
{string}用于标签、名称、可见文本;{int}用于计数;{float}用于小数;{word}仅在值确实是单 token 时使用;- 保持表达式可读:如果一个步骤需要复杂解析逻辑,先问场景措辞是否应该更简单;
- 正则仅用于有界的自然语言备选,例如
/(Web app|Backend service API)/,避免能接受"无人认领的语言"的宽正则。
4.5 步骤定义保持"薄而有意义"
- 步骤是 Gherkin 与自动化之间的胶水,不是第二抽象语言;一个步骤表达一个连贯的领域动作或结果,必要时可以调用多个实现辅助函数,把 UI 机制挡在规格之外;
- Cucumber 为每个场景创建新的 World,场景状态应放在 World 里而不是模块全局变量;
- 一个重要的类型系统约定:Cucumber.js 支持用
world代理配合箭头函数,但 Dify 套件一致地使用async function (this: DifyWorld, ...),让属主关系与 TypeScript 类型推导保持显式。这与 e2e/AGENTS.md 的声明一致:"访问 World 状态的 step definitions 使用async function (this: DifyWorld, ...),箭头函数无法接收 Cucumber 绑定的 World 实例"; - hooks 对 feature 读者不可见,所以只保留低层浏览器生命周期、清理与诊断;业务相关的上下文必须表达在 Gherkin 中。
4.6 有意图地使用标签
标签可以表达稳定的能力分组、选择、fixture 依赖、或条件 hook 意图;但只有当 runner、seed profile 或 hook 拥有该语义时,标签才真正改变运行时行为。换言之:不要发明套件并未实现的标签语义。套件级标签语义(@unauthenticated、@prepared、@external-model、@skip、@axe 等)的完整定义在 e2e/AGENTS.md 的 Tags 章节。
参考文档结尾还给出了一组"审查问题",例如:场景是否读起来像真实的产品行为例子?复用的步骤在这个 feature 中是否仍然为真?新表达式会不会在全局命名空间与现有步骤重叠?新读者不打开 step-definition 文件能否理解结果?
5. Playwright 侧实践:隔离、定位、断言与超时预算
references/playwright-best-practices.md 覆盖定位器、断言、隔离与同步逻辑。
5.1 场景隔离
Playwright 的模型围绕干净的 browser context 构建,保证一个测试不泄漏进另一个:
- 不依赖其它场景先运行过;
- 场景状态放在 runner 的场景属主上下文而非模块全局;
- 特殊认证或会话 setup 通过显式的每场景 fixture建模,而非共享可变状态。
这一原则在 Dify 的 e2e/features/support/world.ts 中有清晰的实现:DifyWorld 类持有 context: BrowserContext、page: Page、consoleRequestContext、各类 createdAppIds/createdAgentIds 资源清单与 scenarioCleanups 队列,并在构造函数中调用 resetScenarioState() 逐字段重置——这正是"每场景新 World + 场景属主状态"的落地形态。多 actor 场景则把每个 actor 放在独立的 BrowserContext 与类型化 DifyWorld 状态中,使诊断与清理覆盖每个 actor。
5.2 按用户契约选择定位器
定位器优先级是一个语义选择而非固定排名:
- 交互控件:role + accessible name;
- 表单控件:关联的 label;placeholder 只有在它是相关且稳定的契约(尤其无 label 时)才使用;
- 非交互内容:可见文本或相关文本替代;
- 无有意义用户可见契约的元素:有意的 test id。
两条禁令值得注意:不要为了迁就定位器而添加错误的 role 或 accessible name——当产品元素本应有用户可见语义时,修契约而不是修定位器;避免裸 CSS/XPath,除非不存在稳定的用户可见契约且添加一个不现实。
另外,定位器对单元素操作是严格的:范围限定到稳定区域,或用 filter({ has, hasText }) 让目标唯一。.first()/.last()/.nth() 应被视为审查信号——位置选择必须是有意且稳定的,而不是为了消歧静默。
5.3 Web-first 断言与"正确的超时属主"
优先自动等待与重试的 Playwright 断言:
await expect(page).toHaveURL(...)
await expect(locator).toBeVisible()
await expect(locator).toBeHidden()
await expect(locator).toBeEnabled()
await expect(locator).toHaveText(...)
要避免的反模式:
expect(await locator.isVisible()).toBe(true)(手动状态检查,失去重试语义);- 为 DOM 状态写自定义轮询循环;
- 把
waitForTimeout当同步手段。
非 DOM 的真值——API 状态、后端最终一致性、生成资源、捕获的浏览器事件——才用 expect.poll。
超时预算是最容易被误解的一点。Cucumber 步骤/hook 超时、Playwright locator/action 超时、Playwright 断言超时是三个独立预算;browserContext.setDefaultTimeout() 不会改变断言默认 5 秒超时。正确做法是把显式的更长断言超时只放在真正需要它的"就绪属主"上,而不是抬高外层超时去掩盖内层失败。这个"三预算"结论与仓库配置互相印证:cucumber.config.ts 设定 Cucumber 层 timeout: 60_000,而 e2e/features/support/hooks.ts 中又按 hook 类型细分了独立预算——closeSessionHookTimeoutMs = 30_000、cleanupHookTimeoutMs = 120_000、diagnosticHookTimeoutMs = 60_000,说明"超时跟着慢的条件走"是被实际执行的规则。
5.4 让 action 等待可操作性
Locator 操作本身已等待元素可操作,不要在每个 click/fill 前堆额外时序逻辑:
- 好模式:当可见状态本身是行为的一部分时先断言它,然后经 locator API 执行 click/fill/select;
- 坏模式:在每个动作前堆任意等待、在用户不关心的不稳定实现细节上等待、用
force: true绕过真实的 hit-target/overlay/disabled 失败。
对一次性弹窗、下载、请求或响应的同步:先注册等待,再触发产生它的动作;场景属主的监听器也可以捕获事件供后续断言。最后一条硬约束:不要用 networkidle 作为应用就绪断言——等用户可见状态或受属主管理的后端契约。
5.5 调试匹配当前 harness
Playwright 支持 trace、截图、页面快照与浏览器日志;但工件捕获要配置在 Cucumber hooks 中,而不是给单个场景加平行诊断。若引入 tracing,使用 browserContext.tracing——@playwright/test 的 runner 选项(trace: 'on-first-retry'、projects、workers、fixtures、reporters、retries)不配置本 harness。这一点再次呼应第 1 节的双框架边界。
Dify 的诊断实现正是钩子驱动:hooks.ts 中对 FAILED/AMBIGUOUS/PENDING/UNDEFINED/UNKNOWN 状态的场景写截图与 HTML 工件到 cucumber-report/artifacts/,与 e2e/AGENTS.md "失败产生截图与 HTML 捕获"的契约一致。
6. 从源码印证:规范如何落在 Dify 的 E2E 包上
技能文档本身是方法论,而 e2e/ 包是它的执行现场。几处关键落点:
- 运行时编排单一入口:scripts/run-cucumber.ts 是唯一 E2E 运行时编排器,拥有服务生命周期、可选 seed 执行、Cucumber 调用与 teardown;cucumber.config.ts 负责报告格式(
progress-bar、summary、HTML 与 NDJSON message 报告)与默认标签表达式。值得注意的是其标签默认逻辑:未显式传--tags时自动注入not @axe and not @prepared and not @external-model and not @external-tool,且任何选择都强制and not @skip——即"确定性子集"的语义由配置层而非每个命令手工拼接。 - 行为门(behavior gate):Cucumber 的退出码是行为门,且 runner 要求至少一条
testCaseStarted消息,防止空标签选择"通过"。这与技能"运行最窄标签场景"的工作流第 6 步配合:窄验证也必须是真的跑了场景。 - API 边界:普通 Console JSON 与可表示的 multipart 操作用带校验的生成 oRPC 客户端直接调用生成操作;API 只能准备 fixture、轮询持久化、清理,不能替代用户的
When动作;验证失败是契约失败,应追到后端 schema 属主并按 api/controllers/API_SCHEMA_GUIDE.md 更新契约,而不是关校验或加兜底 schema。 - 命名空间与能力组织:step definitions 目录按能力划分(
apps/、auth/、agent-v2/、common/),跨能力步骤(如认证步骤)收敛在common/,与"Cucumber 全局匹配命名空间 + 按领域能力组织"的规则一一对应。
7. 落地检查清单
综合技能文档与两份参考文档的 Review Questions,写入或审查 Dify E2E 变更时可对照:
Gherkin 层
- 该旅程是否跨边界,且属主级测试无法证明其结果?
- 场景是否读起来像真实产品行为例子,只证明一个结果?
- 步骤是行为导向还是实现导向?过程性措辞是否为本被测行为所必需?
- 复用的步骤在本 feature 中是否仍然为真?新表达式会不会在全局命名空间与现有步骤重叠?
- 新标签是否在记录真实行为,还是发明了套件未实现的语义?
自动化层 6. 该定位器能否扛过不改变用户可见行为的 DOM 重构? 7. 位置定位器是在表达产品顺序,还是在掩盖歧义匹配? 8. 断言是否使用了 Playwright 的重试语义?显式超时是否属于真正慢的那个条件? 9. 事件等待是否在触发它的动作之前注册? 10. 这段代码是否保持了每场景隔离,有没有绕过 runner 的场景属主上下文与生命周期?
8. 小结
Dify 的 e2e-cucumber-playwright 技能本质上是一份"框架协作宪法":Cucumber 管场景叙事、执行与报告,Playwright 管浏览器行为与断言语义,二者超时域、配置域互不侵入;而 e2e/AGENTS.md 则拥有套件架构与命令契约。写作与审查分别被压缩为可执行的六步工作流、两份按需路由的参考文档,以及一组可勾选的审查问题。其工程价值在于把"E2E 测试最容易腐化的地方"——步骤复用失真的语义、隐式依赖的超时、越界的 API 调用、无主的标签——都变成了有属主、可审查、可复现的显式规则。
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