你的 Gherkin 规格说明正在对你撒谎。
并非故意。它们起初是忠实的。但六个冲刺之后,有人重构了结账流程,却忘了更新 When the user submits payment 这一步。.feature 文件仍然通过,因为 step definition 依然存在。只是它调用的代码早已不再与场景实际描述的行为相符。你拿到了一片绿的测试,以及虚假的安心。这就是 BDD 的默认轨迹——除非你主动与之抗争。
问题不在于开发者懒惰。而在于 .feature 文件与 step definition 之间的关系本质上是松散的。Gherkin 场景是字符串。Step definition 是匹配这些字符串的正则或注解。没有编译器强制要求场景变更必须对应代码变更,反之亦然。工具链默认你会手动保持两者对齐。而你不会。
为什么人工自律在规模面前必然失效
每个团队最初的计划都一样:先写规格,再实现步骤,两边一起更新。第一周这确实行得通。
到了重构阶段就崩了。你在代码里重命名了一个领域概念,但 Gherkin 里还在用旧术语,因为改它意味着要更新十二个 feature 文件,还要拉着产品重新评审。或者你抽离了一条新的校验规则,而现有场景隐式依赖了旧行为,却没人注意到——因为 step definition 被悄悄泛化,让测试继续通过。规格说明渐渐变成了一个平行、且越来越不准确的宇宙。
代价不仅仅是文档过时。而是信任的崩塌。一旦开发者不再相信 feature 文件描述了现实,他们就不再阅读。然后不再编写。最后你又回到了只有单元测试、名字晦涩、与业务方没有共同语言的状态。
“保持同步”到底意味着什么
保持同步不是让测试通过。通过太容易了。同步意味着三件事:
- 每一个 Gherkin 步骤都有一个对应的 step definition,并且它确实在做规格所说的事。
- 每一个 step definition 都至少被一个场景实际执行到。
- 规格里的语言与代码库里的语言一致。
大多数团队只验证第一点,而且是在运行时做的。你需要验证全部三点,并且要在 CI 中、代码合并之前就完成。
用严格绑定实现自动化步骤校验
Cucumber 等工具中松散的字符串匹配是根本原因。你可以通过让 step definition 成为构建时可校验的一级引用来收紧它。
在 TypeScript 或 JavaScript 项目中,你可以把基于正则的 step definition 替换为一个生成的步骤注册表(step registry),将 Gherkin 步骤映射到实际的函数引用。关键是这个映射是生成的,不是手写的,因此如果某个场景引用了一个不存在的步骤,构建就会失败。
下面是一个使用自定义解析器和生成注册表的最小化示例。首先,在构建时解析你的 .feature 文件:
// scripts/validate-steps.ts
import { readFileSync, readdirSync } from 'fs';
import { parse } from '@cucumber/gherkin';
import { IdGenerator } from '@cucumber/messages';
const featureFiles = readdirSync('./features').filter(f => f.endsWith('.feature'));
const allSteps = new Set<string>();
for (const file of featureFiles) {
const content = readFileSync(`./features/${file}`, 'utf-8');
const gherkinDocument = parse(content, new IdGenerator());
for (const feature of gherkinDocument.feature?.children || []) {
for (const step of feature.scenario?.steps || []) {
allSteps.add(step.text);
}
}
}
// Import the actual step registry from your test code
import { stepRegistry } from '../steps/registry';
const registeredSteps = new Set(Object.keys(stepRegistry));
const undefinedSteps = [...allSteps].filter(s => !registeredSteps.has(s));
const orphanedSteps = [...registeredSteps].filter(s => !allSteps.has(s));
if (undefinedSteps.length > 0) {
console.error('Undefined steps:', undefinedSteps);
process.exit(1);
}
if (orphanedSteps.length > 0) {
console.error('Orphaned steps:', orphanedSteps);
process.exit(1);
}
console.log(`Validated ${allSteps.size} steps against ${registeredSteps.size} definitions.`);
你的 step registry 按精确的 Gherkin 文本来暴露函数:
// steps/registry.ts
import { given, when, then } from './step-helpers';
export const stepRegistry: Record<string, Function> = {
'the user is logged in': given.theUserIsLoggedIn,
'the user adds an item to the cart': when.theUserAddsAnItemToTheCart,
'the total should be {int}': then.theTotalShouldBe,
};
这里的 given、when、then 对象都是普通的模块函数。没有正则魔法。如果开发者改了 Gherkin 文本,就必须在注册表里添加对应的条目,否则构建失败。如果删了某个场景,orphaned step 检测会抓住残留的定义。
在合并前把它接入 CI
一个需要开发者在本地运行的脚本,就是一个会被开发者遗忘的脚本。你需要让校验失败直接阻断构建。
把它加进你的测试流水线:
# .github/workflows/ci.yml
jobs:
validate-specs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx ts-node scripts/validate-steps.ts
- run: npm test
关键的细节是 validate-steps.ts 要在实际测试套件之前运行。如果 feature 文件与 step definition 之间存在不匹配,你应该快速失败并给出清晰的错误,而不是跑上一百个 cucumber 场景——它们可能基于过时的逻辑默默通过。
活文档需要生成报告
校验保证语法对齐,但不能保证规格说明可读、有用。为此,你需要一个活文档流水线:从 feature 文件生成 HTML 报告,并在每次合并到 main 时发布。
Cucumber Reports 或 Pickles 等工具可以把你的 .feature 文件变成可浏览的文档。关键是这些文档与 CI 校验的是同一批文件。场景被删,文档里就消失。语言变了,文档自动更新。没有第二个事实来源需要维护。
在 CI 中将报告作为 artifact 发布,或部署到静态站点:
# .github/workflows/docs.yml
jobs:
publish-docs:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: npm install -g @picklesdoc/pickles
- run: pickles --feature-directory=./features --output-directory=./docs
- uses: actions/upload-pages-artifact@v3
with:
path: ./docs
利益相关者不需要读原始的 Gherkin。他们需要一个可读的页面,并且相信它是最新的。自动化建立这种信任。
权衡:严格性与表达力
注册表方式有代价。你失去了正则模式的灵活性,比如 /^the user adds (\d+) items? to the cart$/。每一种变体都要显式写成一条,或者使用带类型占位符的参数化步骤。这很啰嗦。
另一种方案是保留正则,但增加一个更严格的 linter:当某个模式过于宽泛,或者某段步骤文本匹配不到任何已知模式时发出警告。结合 Cucumber 内置的 dry-run 和 publish 标志,再配一个检查 unused step definition 的自定义 linter,你可以用 20% 的啰嗦换取 80% 的安全。
# Dry-run parses all features without executing them, surfacing undefined steps
npx cucumber-js --dry-run
这不如注册表方式严格。它能抓到未定义的步骤,但抓不到 orphaned steps,也不强制语义对齐。对于已有大量测试套件的团队,这是一个务实的起点。对于新项目,注册表方式在一个月内就能回本。
我们试过但没用的方法
我们尝试过从代码注释生成 Gherkin。思路是让开发者给测试方法加注解,然后工具自动生成 .feature 文件。结果失败了,因为 Gherkin 本来就该让非开发者也能读懂。从方法名生成的文字不可读。那甚至不能算文字。
我们也试过要求每次改规格都必须结对编程。有帮助,但无法规模化。问题是机制层面的,解法也应该是机制层面的。
今天就从未定义步骤检测开始
如果你已经有一套 Cucumber 测试套件,最小而有用的改动就是在 CI 流水线里加上 --dry-run。五分钟搞定,它能抓住最常见的漂移:重构后的场景不再匹配任何 step definition。
如果你从零开始,可以考虑基于注册表的方式。显式映射的前期成本,会被构建时保证和放心重构的信心所偿还——再也不用担心规格说明在无声中腐烂。
你的 Gherkin 规格说明应该描述系统做了什么。如果你无法信任它们做到这一点,那它们只不过是昂贵的注释。要么自动化检查让它们保持诚实,要么接受它们会对你撒谎的现实。