迁移至 nukadoko
用途
将现有套件逐步迁移至 nukadoko,一次迁移一部分——而非重写。 起始点分为以下两种:
- cucumber-js 套件——包含 feature 文件及胶水代码,通常驱动 Playwright。
- 已具备类型化步骤的套件——拥有自己的 DSL,并非 Cucumber 风格,但与 nukadoko 的
defineStep足够接近,迁移主要涉及翻译。
无论哪种情况,迁移都分阶段进行,而非一次性完成;本技能后续内容会解释原因,并依次讲解各起始点的迁移步骤。
以下内容均假定项目已初始化。若未初始化(缺少 nukadoko.config.ts),请先运行 nuka init。
两个阶段,绝不一次完成
一次只改动一件事,然后进行下一件——绝不同时改动两件事。如果两步变更同时落地后步骤出错,就无法判断是哪一步导致的:因为失败的原因纠缠在一起。将工作拆分为不同阶段,可以确保每次失败都能追溯到唯一的变更。这正是关键所在——阶段划分本身并非最终目的,可追溯性才是。
如果下面的阶段划分不适用于您的起始点,请根据此原则自行推导两个阶段。需要保证的是可追溯性,而非这些特定的阶段名称。
以下给出两个实际应用场景:cucumber-js 套件拆分为“切换导入”和“添加类型”(见下文);已具备类型化步骤的套件则采用不同的拆分方式,将在后续介绍。
从 cucumber-js 套件迁移
这是上文两个阶段命名的对应场景。
阶段 1 — 让它运行起来
修改每个胶水文件中的导入语句:
// before: import { Given, When, Then } from "@cucumber/cucumber";
import { Given, When, Then } from "nukadoko/compat";
扩展名决定了发现机制读取的内容,而非套件自身的历史:.ts、.mts、.js 和 .mjs 胶水文件均以相同方式读取,因此一直使用纯 JavaScript 的套件无需为进入此阶段而重写为 TypeScript。.cjs 文件是唯一的例外:nukadoko 仅支持 ESM,因此发现机制绝不会导入此类文件;nuka check 会明确指出该文件(错误类型为 step-file-unsupported-extension),而不是让其悄然消失导致无法解释的 undefined-step。将其重命名为 .js 仅在该文件自身的代码已采用 ES 模块语法时才有效;若仍使用 require(...),则会在同一导入处因同样的 ESM 限制而失败,nuka check 会报告 step-file-import-failed。
然后运行 nuka check <feature> 和 nuka run <feature>,并阅读它们的输出。修复它们指出的问题,然后重新运行。重复此过程,直至现有套件全部通过——这就是阶段 1 的完成条件,仅此而已。该条件是指 nuka check 和 nuka run 通过,而非 tsc 类型检查干净;两者可能在任何方向上不一致。
在整个阶段 1 中,nuka steps 和 nuka describe 始终保持可用,即使某些胶水文件仍无法导入:两者均逐个读取步骤文件,因此仍无法导入的文件会被明确标记(--json 模式下为 import_failures,否则输出至 stderr),而不会清空整个词汇表。请随迁移进度随时读取词汇表,不必等待所有文件都顺利导入后再操作;nuka run <feature> 和 nuka do <step> 在遇到不可读的胶水文件时会直接拒绝执行(这是有意的设计,因为它们即将执行操作)。
在开始之前,请不要去寻找 compat 不支持的功能列表。任何无法正常工作的内容都会以明显方式失败,要么在导入时,要么在第一次 nuka run 时——失败信息会明确指出问题所在,这就是您需要处理的问题。此处列出的列表在 compat 支持范围扩展后很快就会过时;而 CLI 自身的输出永远不会过时。
阶段 2 — 赋予契约
套件运行起来后,某些步骤值得添加类型。以下是将一个 compat 步骤改为类型化步骤时发生的变化:
| compat 步骤 | 类型化步骤 | |
|---|---|---|
| 输入 | 仅模式捕获,不检查 | 通过 args 模式验证,每个字段携带 .describe() |
| 输出 | 丢弃——凭据的 result 为 null | 通过 returns 模式验证,并存储在凭据中 |
| 依赖 | 对 World 的副作用,在函数签名中不可见 | 使用 from 声明,在 import 中命名,在运行前由 nuka check 检查,并在凭据中记录为 used |
| 关键字 | 装饰性——绑定到 Then 的步骤仍可进行突变 | mutates 是 nukadoko 信任的声明:声明 mutates: true 后,只读环境将拒绝执行,且 nuka check 会在其绑定到 Then 时发出警告;实际运行的内容仍会记录在凭据的 observed 计数中 |
| 单独运行 | 不可能(场景之外 World 为空) | nuka do <step> 可直接运行,凭据打印到标准输出;from 键来自 --args(与其它键相同),或来自 --use <receipt-id>(用于引用之前执行的结果);run 内部的 resultOf fixture 调用仍会找不到内容,因为没有场景可供遍历 |
最后一行与其上方的“依赖”行是独立的事实,而非其后果:compat 步骤无法单独运行,因为它所需的内容存在于尚未被填充的 World 上,而 World 不在其签名中——无法检查以确定需要预先设置什么。类型化步骤的依赖通过 from 条目命名,在 import 中可见,因此 nuka do 调用可以手动提供它们:普通键使用 --args,或使用 --use <receipt-id> 引用之前执行的结果——上游步骤的名称无需出现在命令行中,因为所引用的凭据已携带该名称。所有键均通过 --use 获得的步骤甚至不需要 --args:仅 --use 本身就构成一次完整的调用。在场景之外仍找不到内容的是通过 run 内部的 resultOf fixture 读取的依赖:该调用没有可遍历的链,因为不存在场景来构建这样的链,无论是否使用 --use。
优先提升您最常运行的步骤,一次一个——而不是一次性处理整个套件。如何重写任何一段胶水代码,取决于您做出决定的当下判断;nukadoko 不规定单一方案,本技能也不会。
起草类型化步骤的 run 时,可能会先接触到未迁移的 run(ctx, args) 结构,然后才完全解构。nuka steps --json 已经指出了该草案接触了哪些 fixture,标记为 needs_inferred:这是通过 ctx 自身成员访问进行的词汇猜测,同时 needs: null 以及 needs_error 解释了为何无法读取真正的契约。这可以避免手动重新读取整个主体来知道需要在解构后的签名中添加什么,但这只是猜测,而非契约:它会遗漏别名(const c = ctx),并且永远不会命名 needs_browser,因此请完成解构,而不要将其视为已完成。
提升后的步骤示例
一个示例。之前,compat 胶水代码:
const { Given } = require("@cucumber/cucumber");
Given("a project {string} exists", async function (name) {
const res = await this.request.post("/projects", { data: { name } });
this.projectId = (await res.json()).id;
});
之后,类型化步骤(features/steps/create-project.ts):
import { defineStep } from "nukadoko";
import { z } from "zod";
export default defineStep({
pattern: "a project {name:string} exists",
description: "Create a project and return its id",
args: z.object({
name: z.string().describe("the project's display name"),
}),
returns: z.object({
id: z.string().describe("the created project's id, for later steps to cite"),
}),
mutates: true,
async run({ request }, args) {
const res = await request.post("/projects", { data: args });
return res.json();
},
});
变化点:
this.projectId已移除;id 改为通过returns返回。后续步骤声明from: { projectId: [createProjectStep, "id"] }以通过键读取——nuka check在任何内容运行前验证绑定顺序,且该读取操作会显示在该后续步骤自身的凭据中(resultOffixture 仍可用于键名称无法表达的更罕见读取场景)。- 参数是绑定到模式键的命名捕获(
{name:string}→args.name),因此模式本身即可显示哪些文本映射到哪个字段。 mutates: true现在是一个声明,而不仅仅是关于代码恰好做什么的事实——nuka check在该步骤绑定到Then时会发出警告,且只读环境会完全拒绝执行它。凭据仍会记录运行实际发送的内容,以供审查,但该计数不会导致步骤被拒绝。this.request变为requestfixture,通过解构第一个参数命名:只有执行器实际注入的内容才能在此处命名,没有隐式内容,且只有实际命名的名称才会被构建。未命名page或context的步骤永远不会启动浏览器。
这是本处唯一的工作示例。它并非 compat 与类型化之间所有差异的目录——如果另一个模式频繁出现值得一个示例,那将是独立的补充,而非从这个单一案例中即兴发挥。
当全局 After 钩子是清理逻辑时
cucumber-js 套件经常依赖全局 After 钩子来清理步骤创建的内容,如租户、数据行、临时文件等。一旦创建该资源的步骤被提升,就值得重新审视:将资源声明为 nukadoko.config.ts 中 fixtures 下的 fixture,清理逻辑直接写在 await use(...) 之后,与构建逻辑在同一函数中,而不是留在必须仅凭标签猜测哪些场景实际需要它的钩子中。defineFixtures(来自 nukadoko 包)使 fixture 完全类型化;任何类型化步骤都可通过解构名称来访问它,就像访问 page 或 request 一样。这是增量改进,而非强制要求:清理类型化步骤尚未声明的资源的 After 钩子,其有效性等同于阶段 1 中的状态。
从类型化步骤形状的 DSL 迁移
如果没有 feature 文件,也没有 cucumber 胶水代码,则上述阶段 1 的 compat 入口不适用——请直接跳过。
之所以可行,是因为 pattern 在 defineStep 中是可选的:步骤可以完全不定义模式,仍然作为完整的纯 CLI 词汇表存在,可通过 nuka do 运行,并通过 nuka describe 检查。这产生了基于相同原理的不同阶段对:先将每个步骤移动到类型化的 defineStep,然后在 feature 文件使其值得时,再将其打包到 Gherkin 的 pattern 中。
如果源 DSL 已经携带了类似 description、args、returns、mutates 和 run 函数的内容,则转换为 defineStep 是直接的——每个都有对应的 defineStep 字段来接收它。
不应做的事
- 不要同时执行阶段 1 和阶段 2。 在同一次变更中混合导入切换和类型添加,正是导致失败无法追溯的原因——请参见上文“两个阶段,绝不一次完成”。
- 不要让套件在迁移中途停止运行。 Compat 步骤和类型化步骤可以在同一个 feature 文件中共存;部分步骤已提升、其余仍为 compat 的套件必须始终保持测试通过。永远不要将“完全类型化”作为“能运行”的前提条件。
- 不要在其类型化替换版本编写并测试通过之前删除 compat 胶水代码。 先编写新步骤,使其通过
nuka do运行,然后才删除旧步骤——绝不要反过来。 - 不要猜测
nuka check或nuka run未要求修复的内容。 它们的输出才是问题所在的证据;修改它们未标记的内容是基于直觉的修改,而非基于实际失败原因。