首页
/ Dify E2E 测试实践:Cucumber Gherkin 场景、步骤定义与参数表达式的最佳实践

Dify E2E 测试实践:Cucumber Gherkin 场景、步骤定义与参数表达式的最佳实践

2026-09-06 17:28:49作者:管翌锬

本文基于 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:期望结果。

需要强调的是两个易被误读的点:

  1. Given/When/Then 阶段的重复不是自动失败,而是一个“重新审视叙事”的信号;
  2. 避免隐藏依赖:场景之间不得依赖彼此的副作用。

此外,原文档对 RuleBackground 给出了明确取舍:

  • 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 中被自然复用,措辞在每处都保持诚实,恰好落在“原则三”的正当复用区间内。

原则五:保持步骤定义薄而有意义

原文档对步骤定义给出了三层约束:

  1. 步骤定义是 Gherkin 与自动化之间的胶水,不是第二套抽象语言。一个步骤应表达一个连贯的领域动作或结果;为了让 UI 机制不进入规格层,它可以内部调用多个实现辅助函数。
  2. 状态放在 World 里,而不是模块全局变量。Cucumber 会为每个场景创建新的 World 实例,因此场景状态必须挂在 World 上以保证场景间隔离。
  3. 钩子对 feature 读者是不可见的。把钩子留给低层的浏览器生命周期、清理与诊断;凡与业务相关的上下文,一律表达在 Gherkin 中。

DifyWorld:每条原则的源码级落地

Dify 的 World 实现 是这一原则最完整的证据。DifyWorld 继承自 @cucumber/cucumberWorld,并通过文件末尾的 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(),把 createdAppIdslastCreatedAppNameagentBuilder 等字段归零。这直接落实了“场景状态放 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.mde2e/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 与步骤定义时的完整检查表,建议逐条过:

  1. 这个场景读起来像产品行为的真实示例吗?
  2. 它证明的是一个结果,还是把几个独立阶段揉在了一起?
  3. 步骤是面向行为的,还是面向实现的?
  4. 过程性措辞对“被测行为”是否必要?
  5. 一个被复用的步骤在这个 feature 里仍然保持诚实吗?
  6. 新表达式是否会在全局命名空间中与已有步骤重叠?
  7. Rule 能澄清业务规则,还是 Background 会隐藏重要的 setup?
  8. 新标签是在记录真实行为,还是在发明套件并未实现的语义?
  9. 一个新读者在不打开步骤定义文件的情况下,能理解这个场景的结果吗?

落地:如何在 Dify 仓库中运行与验证

规范最终要落到可执行的命令上。根据 e2e/AGENTS.md,从仓库根目录执行(依赖与浏览器只需安装一次:pnpm installpnpm -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 套件的工程师来说,这套实践的价值不在于让测试跑起来,而在于让测试在半年后依然读得懂、找得到、信得过。

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