首页
/ Dify 的 E2E 测试方法论:Cucumber + Playwright 协作规范与落地实践

Dify 的 E2E 测试方法论:Cucumber + Playwright 协作规范与落地实践

2026-09-06 17:23:22作者:平淮齐Percy

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/testtest.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 用例从立项到验证的完整决策链:

  1. 只为跨边界关键用户旅程加 E2E 覆盖。判据是:这条旅程的结果(outcome)跨越边界,且更便宜的属主级测试(owner-level tests,如组件/服务单测)已经无法证明它。E2E 是最贵的测试层,默认不该写。
  2. 先识别用户可见行为及其 feature 属主。从真实产品默认值和 actor 角色出发;setup 可以建立前置条件,但不得人为制造相反状态来让场景显得有意义(例如故意把默认开启的功能关掉再测"开启它")。
  3. 按需读取文件:目标场景、匹配的 step definitions;只有当会话或共享状态确实相关时,才读取生命周期文件(hooks)。
  4. 步骤复用优先:措辞与行为都匹配时复用已有 step;不匹配时新增一个连贯的场景或步骤,而不是写一个含义模糊的"通用"步骤。
  5. 边界原则:浏览器操作与断言放在公开用户边界(public user boundary);setup、seed、轮询、cleanup 放在各自包定义的属主处。
  6. 变更验证范围最小化:改动后运行 e2e/AGENTS.md 中记录的最窄标签场景与包级检查;只有涉及共享 hooks、标签或 support 代码时才扩大验证范围。

对应到实际运行方式,最窄验证通常就是一条标签命令,例如:

# 只跑 @smoke 标签的场景
pnpm -C e2e e2e -- --tags @smoke

标签命令的完整矩阵(e2ee2e:fulle2e:preparede2e:externale2e:headed、无障碍扫描 e2e:accessibility:a / e2e:accessibility:aae2e:resete2e: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 用于期望结果;阶段"回环"是重新审视叙事的信号,而不是自动失败;
  • 避免对其它场景副作用的隐藏依赖;
  • 多个例子说明同一条具名业务规则时用 RuleBackground 只用于读者理解所必需的共享上下文,不要把 fixture 准备、运行时就绪或长 setup 藏进 Background

4.3 步骤复用:只在行为真正一致时

复用的判据是四条同时成立:

  • 用户动作确实相同
  • 期望结果确实相同
  • 措辞在不同 feature 中保持自然;
  • 参数是真实的产品领域值(具名表面、模式、资源或状态)。

行为实质不同、复用旧措辞会产生误导、或"通用步骤"退化为实现细节包装器时,应新写步骤。原文总结得很到位:"不要通过制造模糊步骤来优化步骤数量,要优化为一组真实、领域拥有的小步骤集。"

一条实现层面的约束同样重要:step definitions 共享一个全局匹配命名空间,与所在目录无关。因此按领域能力组织(capability-oriented),避免 feature 耦合的 glue 或宽到能与不相关行为重叠的表达式。这在 Dify 的目录结构中得到体现:e2e/features/step-definitions/ 下按 accessibilityagent-v2appsauthcommon 划分,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: BrowserContextpage: PageconsoleRequestContext、各类 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_000cleanupHookTimeoutMs = 120_000diagnosticHookTimeoutMs = 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-barsummary、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 层

  1. 该旅程是否跨边界,且属主级测试无法证明其结果?
  2. 场景是否读起来像真实产品行为例子,只证明一个结果?
  3. 步骤是行为导向还是实现导向?过程性措辞是否为本被测行为所必需?
  4. 复用的步骤在本 feature 中是否仍然为真?新表达式会不会在全局命名空间与现有步骤重叠?
  5. 新标签是否在记录真实行为,还是发明了套件未实现的语义?

自动化层 6. 该定位器能否扛过不改变用户可见行为的 DOM 重构? 7. 位置定位器是在表达产品顺序,还是在掩盖歧义匹配? 8. 断言是否使用了 Playwright 的重试语义?显式超时是否属于真正慢的那个条件? 9. 事件等待是否在触发它的动作之前注册? 10. 这段代码是否保持了每场景隔离,有没有绕过 runner 的场景属主上下文与生命周期?

8. 小结

Dify 的 e2e-cucumber-playwright 技能本质上是一份"框架协作宪法":Cucumber 管场景叙事、执行与报告,Playwright 管浏览器行为与断言语义,二者超时域、配置域互不侵入;而 e2e/AGENTS.md 则拥有套件架构与命令契约。写作与审查分别被压缩为可执行的六步工作流、两份按需路由的参考文档,以及一组可勾选的审查问题。其工程价值在于把"E2E 测试最容易腐化的地方"——步骤复用失真的语义、隐式依赖的超时、越界的 API 调用、无主的标签——都变成了有属主、可审查、可复现的显式规则。

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