Dify E2E 测试实践:Cucumber Gherkin 场景、步骤定义与参数表达式的最佳实践
本文基于 Dify 仓库内置的 E2E 测试技能文档 cucumber-best-practices.md 展开,讲解在 Dify 的 e2e/ 目录(Cucumber + Playwright 组合)中编写与评审 Gherkin 场景、步骤定义(Step Definitions)、参数表达式(Cucumber Expressions)以及步骤复用时应遵循的方法论。读完后你将掌握:如何把场景写成“可执行的产品行为规格”、如何在 Dify 的全局步骤命名空间中做真实而非生硬的复用、如何用 {string} 等表达式保持 Gherkin 可读性,以及标签(Tag)体系与 World 状态管理的源码级实现依据。
背景:这份规范在 Dify 测试体系中的位置
Dify 的仓库级 E2E 套件位于 e2e/,其架构由 e2e/AGENTS.md 定义:Cucumber 负责场景与钩子(Hook)的调度和报告,Playwright 提供浏览器自动化、上下文、定位器、断言、请求与追踪 API。配套的仓库级技能文档 SKILL.md 明确了主题路由:场景措辞、步骤粒度、表达式、World 状态、钩子可见性或标签设计问题,归属本文所讲的 Cucumber 最佳实践参考文档;而定位器、断言、隔离与等待决策则归属 playwright-best-practices.md。
也就是说,本文的规范不是泛泛而谈的 BDD 口号,而是直接服务于 Dify 这套真实运行的回归套件:默认场景复用共享的已认证存储状态,标签驱动选择范围,Cucumber 退出码是行为门禁,且 runner 要求至少有一条 testCaseStarted 消息以防止空的标签选择“假通过”。
原则一:把场景当作可执行规格来写
原文档的第一条核心建议是:Cucumber 场景应当以声明式方式描述行为,而不是复现一段交互脚本。具体做法:
- 写“用户做了什么、应当发生什么”;
- 避免 UI 内部措辞,如选择器细节、DOM 结构或组件名;
- 语言要足够具体,使场景读起来像“活文档”(living documentation)。
唯一的例外:当交互机制本身就是被测行为时,过程性措辞是合理的,例如键盘导航、焦点移动,或必须按顺序执行的多角色(multi-actor)序列。Dify 套件中确实存在这类场景——@browser-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
整个场景没有任何选择器或 DOM 词汇:Given 建立初始上下文,When 是用户动作,Then/And 描述用户可观察的结果。对应的步骤定义 navigation.steps.ts 内部虽然使用了 getByRole 等 Playwright 定位手段,但这些实现细节全部封装在 glue 层,不会泄漏到 Gherkin 文本中——这正是“规格层与自动化层分离”的示范。
原则二:保持场景聚焦
原文档要求一个场景证明一个业务规则或一个连贯的结果。Cucumber 社区“三到五个步骤”的建议只是评审启发式,而非硬性上限:当场景变长时,应检查是否存在多个结果、顺带的 setup、或可以下沉到领域步骤背后的 UI 流程。
Gherkin 关键字的分工约定:
Given:初始上下文;When:触发事件;Then:期望结果。
需要强调的是两个易被误读的点:
- Given/When/Then 阶段的重复不是自动失败,而是一个“重新审视叙事”的信号;
- 避免隐藏依赖:场景之间不得依赖彼此的副作用。
此外,原文档对 Rule 和 Background 给出了明确取舍:
- 用
Rule包装“多个示例说明同一条具名业务规则”的情形; Background只用于读者理解场景所必需的共享上下文,不要在里面隐藏 fixture 准备、运行时就绪检查或冗长 setup。
Dify 套件对此有配套约束:种子脚本(seed)拥有共享的长生命周期 fixture,场景拥有自己创建的临时资源并必须注册清理(见 e2e/AGENTS.md 的 “Seeds, Cleanup, And Diagnostics” 一节)。这保证了 Background 里不会出现“偷偷初始化环境”的黑盒,fixture 的归属始终可追溯。
原则三:复用步骤,但只在行为真正一致时复用
原文档将“坏的复用”概括为一句话:好的复用减少重复,坏的复用隐藏语义。
应当优先复用的情形:
- 用户动作确实是同一个动作;
- 期望结果确实是同一个结果;
- 措辞在不同 feature 中仍然自然;
- 参数是真实的产品领域值,如具名的界面、模式、资源或状态。
应当另写新步骤的情形:
- 行为存在实质性差异;
- 强行复用旧措辞会让场景产生误导;
- 所谓“通用步骤”实际上变成实现细节的包装器。
总结论是:不要为了压低步骤数量而制造模糊步骤,而是优化出一组“真实、由领域拥有”的步骤。
Dify 的步骤定义目录结构直接体现了这一条。e2e/features/step-definitions/ 按能力域(capability)组织:apps/、auth/、accessibility/、agent-v2/ 等,common/ 只留给真正跨能力的步骤。同时有一个关键的运行时事实——所有步骤定义共享同一个全局匹配命名空间,与它们所在的目录无关。目录只是代码组织方式,不是命名空间隔离。因此:
- 步骤定义的组织要按领域能力划分,避免与单个 feature 耦合的 glue;
- 表达式不能宽到与不相关行为重叠,否则全局匹配会发生歧义或冲突。
以 create-app.steps.ts 为例,I select the {string} app type 步骤的参数是“Chatbot / Workflow”这类真实的产品领域值,动作和结果在多个 feature 中语义一致,属于教科书式的正当复用;而 I confirm app creation 内部包含了等待 POST /console/api/apps 响应、用 zPostAppsResponse 做契约校验、并把新建应用 ID 记入 World 状态等实现细节——这些细节被封装在步骤内部,Gherkin 层只保留一个连贯的领域动作。
原则四:优先使用 Cucumber Expressions
原文档要求:除非正则确有必要,否则一律使用 Cucumber Expressions。常用形式:
| 表达式 | 适用场景 |
|---|---|
{string} |
标签、名称、可见文本 |
{int} |
计数 |
{float} |
十进制数值 |
{word} |
仅当值确实是一个不可分割的单词(token)时 |
两条配套纪律:
- 保持表达式可读。如果一个步骤需要复杂的解析逻辑,先反问:是不是场景措辞本身应该更简单?
- 只在它能让 Gherkin 保持可读时,才用有界的自然语言正则替代,例如原文档给出的例子
/(Web app|Backend service API)/;要避免接受“无主语言”(unowned language)的宽正则。
Dify 的现有步骤定义大量使用 {string},且用法克制。例如 navigation.steps.ts 中的 I should see the {string} button:
Then('I should see the {string} button', async function (this: DifyWorld, label: string) {
await expect(this.getPage().getByRole('button', { name: label })).toBeVisible()
})
参数 label 是按钮的可见文本——一个真实的产品领域值,而不是颜色、索引或内部 ID。这类步骤在 smoke、认证、应用管理等多个 feature 中被自然复用,措辞在每处都保持诚实,恰好落在“原则三”的正当复用区间内。
原则五:保持步骤定义薄而有意义
原文档对步骤定义给出了三层约束:
- 步骤定义是 Gherkin 与自动化之间的胶水,不是第二套抽象语言。一个步骤应表达一个连贯的领域动作或结果;为了让 UI 机制不进入规格层,它可以内部调用多个实现辅助函数。
- 状态放在 World 里,而不是模块全局变量。Cucumber 会为每个场景创建新的 World 实例,因此场景状态必须挂在 World 上以保证场景间隔离。
- 钩子对 feature 读者是不可见的。把钩子留给低层的浏览器生命周期、清理与诊断;凡与业务相关的上下文,一律表达在 Gherkin 中。
DifyWorld:每条原则的源码级落地
Dify 的 World 实现 是这一原则最完整的证据。DifyWorld 继承自 @cucumber/cucumber 的 World,并通过文件末尾的 setWorldConstructor(DifyWorld) 注册为全局构造器:
export class DifyWorld extends World {
context: BrowserContext | undefined
consoleClient: ConsoleClient | undefined
page: Page | undefined
consoleErrors: string[] = []
pageErrors: string[] = []
createdAppIds: string[] = []
createdAgentIds: string[] = []
scenarioCleanups: ScenarioCleanup[] = []
// ...
}
setWorldConstructor(DifyWorld)
几个值得注意的设计点,与原文档规范逐条对应:
- 每场景新 World + 显式状态重置:构造函数与
startSession都会调用resetScenarioState(),把createdAppIds、lastCreatedAppName、agentBuilder等字段归零。这直接落实了“场景状态放 World、不放模块全局”的隔离要求,也让“场景之间无隐藏副作用依赖”在代码层面可验证。 - 浏览器与 API 身份分离:
startSession中,浏览器上下文仅在authenticated时注入storageState,而consoleRequestContext始终持有独立的 API 请求上下文。e2e/AGENTS.md 对此的解释是:让浏览器身份与 API 身份保持分离,未认证(unauthenticated)和登出(logout)旅程才不会把 fixture 的 ownership 搞乱。 - 清理是 World 的一等公民:
registerCleanup(...)收集场景内注册的清理回调,runRegisteredCleanups以 LIFO 顺序执行并与类型化清理队列配合。这支撑了“场景拥有自己创建的临时资源并必须注册清理”的契约。 - 类型化的
this: DifyWorld写法:Cucumber.js 支持通过world代理在箭头函数中访问 World,但 Dify 套件一致性地使用类型化的async function (this: DifyWorld, ...)函数式写法(SKILL.md 与 e2e/AGENTS.md 均将其列为硬性约定)。原因是箭头函数无法接收 Cucumber 绑定的 World 实例,而显式的this注解让 World 的所有权和 TypeScript 类型发现都保持明确。仓库中所有步骤定义(如 create-app.steps.ts)都严格遵循这一模式。
一个“薄步骤”的完整示例,create-app.steps.ts 中的 I confirm app creation:
When('I confirm app creation', async function (this: DifyWorld) {
const page = this.getPage()
const createButton = page.getByRole('dialog').getByRole('button', { name: /^Create(?:\s|$)/ })
const responsePromise = page.waitForResponse(/* 匹配 POST /console/api/apps */)
await expect(createButton).toBeEnabled()
await createButton.click()
const response = await responsePromise
expect(response.ok()).toBe(true)
const createdApp = zPostAppsResponse.parse(await response.json())
this.createdAppIds.push(createdApp.id)
})
Gherkin 里只有一个连贯动作“我确认创建应用”,而按钮定位、响应等待、契约校验(zPostAppsResponse 是生成的 oRPC 客户端的 zod schema)、状态记录这些实现细节全部被封装在步骤内部——规格保持声明式,自动化保持完整。
原则六:有意地使用标签
原文档对标签的界定是:标签可以传达稳定的能力分组、选择范围、fixture 依赖或条件钩子意图;只有当 runner、seed profile 或钩子真正“拥有”这个含义时,标签才会改变运行时行为。换句话说,一个不被任何运行逻辑解释的标签,就是纯粹的自欺。
Dify 的标签体系(定义于 e2e/AGENTS.md 的 “Tags And External Runtime” 一节)是这条原则的完整实例:
| 标签 | 运行时归属 |
|---|---|
@unauthenticated |
创建干净(无存储状态)的浏览器上下文 |
@authenticated |
仅是意图与选择标签,不改变运行行为 |
@smoke |
用于选择冒烟子集,如 pnpm -C e2e e2e -- --tags @smoke |
@axe / @wcag-a / @wcag-aa / @wcag-page-<slug> |
WCAG 独立扫描体系,被默认功能套件排除;选级别时必须同时选 @axe |
@prepared |
依赖 seed 的 prepared fixture(post-merge seed profile 会提供) |
@external-model / @external-tool |
依赖真实外部运行时,确定性命令默认排除,外部命令才可选 |
@microphone |
使用检入的假音频 fixture 与隔离的 Chromium 上下文 |
@browser-smoke |
在 Chromium 与 WebKit CI 通道运行聚焦的键盘与导航覆盖 |
@skip |
从所有 runner profile 中临时排除;产品行为恢复后必须移除,禁止用于永久或环境相关的抑制 |
@agent-backend-runtime |
Agent v2 运行时场景,需要显式的运行时可用性步骤与 E2E_START_AGENT_BACKEND=1 或后端 URL |
标签在 runner 层如何“被拥有”,可以在 cucumber.config.ts 中直接看到:
const defaultNonExternalTags =
'not @axe and not @prepared and not @external-model and not @external-tool'
const selectedTags =
process.env.E2E_CUCUMBER_TAGS || (hasCliTags ? undefined : defaultNonExternalTags)
const tags = selectedTags ? `(${selectedTags}) and not @skip` : 'not @skip'
const config = {
format: ['progress-bar', 'summary', 'html:./cucumber-report/report.html', 'message:./cucumber-report/report.ndjson'],
import: ['./tsx-register.js', 'features/**/*.ts'],
paths: ['features/**/*.feature'],
tags,
timeout: 60_000,
}
这段配置说明了两件事:默认命令会自动排除 WCAG 扫描、prepared fixture 场景与外部运行时场景,且无条件排除 @skip;而 @smoke、@authenticated 这类标签只有在选择器层面生效。原文档最后一问——“新标签是在记录真实行为,还是在发明套件并未实现的语义?”——在这套配置下有非常具体的检验标准:如果 runner、seed 或钩子都不解释它,就不要加它。
评审清单:合并前的九个问题
原文档给出的 Review Questions 是评审 Gherkin 与步骤定义时的完整检查表,建议逐条过:
- 这个场景读起来像产品行为的真实示例吗?
- 它证明的是一个结果,还是把几个独立阶段揉在了一起?
- 步骤是面向行为的,还是面向实现的?
- 过程性措辞对“被测行为”是否必要?
- 一个被复用的步骤在这个 feature 里仍然保持诚实吗?
- 新表达式是否会在全局命名空间中与已有步骤重叠?
- 用
Rule能澄清业务规则,还是Background会隐藏重要的 setup? - 新标签是在记录真实行为,还是在发明套件并未实现的语义?
- 一个新读者在不打开步骤定义文件的情况下,能理解这个场景的结果吗?
落地:如何在 Dify 仓库中运行与验证
规范最终要落到可执行的命令上。根据 e2e/AGENTS.md,从仓库根目录执行(依赖与浏览器只需安装一次:pnpm install 与 pnpm -C e2e e2e:install;同一时间只运行一个本地 e2e* 进程,因为 runner 共享端口、认证状态与日志路径):
# 对已有初始化好的实例直接运行
pnpm -C e2e e2e
# 重置、初始化并运行确定性场景
pnpm -C e2e e2e:full
# 只跑冒烟子集(标签选择)
pnpm -C e2e e2e -- --tags @smoke
# 有头模式调试(可配合 E2E_SLOW_MO=500 放慢操作)
pnpm -C e2e e2e:headed -- --tags @smoke
# 依赖 prepared fixture 的场景
E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:prepared
结果产物:失败会在 cucumber-report/artifacts/ 生成截图与 HTML 捕获,HTML 报告与 Cucumber Messages 报告位于 cucumber-report/ 下,前后端启动日志在 .logs/。静态检查可用 vp check e2e。写改动时的纪律与 SKILL.md 的工作流一致:先复用措辞与行为都匹配的既有步骤,都不匹配时再加一个连贯的场景或步骤;改动后跑最窄的标签化场景,只有当改动触及共享钩子、标签或 support 代码时才扩大运行范围。
小结
这份规范的核心思想可以浓缩为一句话:让 Gherkin 停留在产品行为层,把机制、状态与清理压进它们各自的 owner。在 Dify 的 e2e 套件中,这体现为按能力域组织的步骤定义与全局唯一命名空间、类型化 this: DifyWorld 的薄步骤、每场景重建的 World 状态与 LIFO 清理注册表、以及每个标签都有 runner/seed/钩子解释的标签体系。对维护 E2E 套件的工程师来说,这套实践的价值不在于让测试跑起来,而在于让测试在半年后依然读得懂、找得到、信得过。
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