开发自定义 Node
自定义 Node 用于将业务操作封装为可在 YAML 用例中直接调用的步骤。本文介绍如何定义与校验输入参数、在多个 Node 之间共享上下文数据,以及处理异步取消与资源清理。
关于平台环境、Agent 接入和运行参数配置,请参阅配置测试工程。
- 注册自定义业务 Node:定义业务操作、在 YAML 中调用,并通过生成 Node 说明书验证注册结果。
- Node 执行参数:区分业务输入参数(
input)与框架级步骤控制参数($)。 - 跨 Node 共享上下文:在多个步骤之间创建、读取和清理共享测试数据。
- 进阶用法:返回结构化结果、处理错误与取消信号,以及管理资源生命周期。
注册自定义业务 Node
自定义 Node 从 YAML 步骤中接收参数并执行对应的 TypeScript 逻辑。下面以创建测试用户数据为例,演示如何将用户信息写入本地 JSON 文件(实际业务中可替换为调用后端接口或写入数据库)。
1. 定义并注册 Node
在 midscene.config.ts 中使用 defineNode() 定义节点,并将其加入 nodes 数组:
各字段的作用如下:
name:在 YAML 中调用该操作时使用的名称。inputSchema(可选):使用 Zod 定义输入参数结构。定义后,Midscene Test 会在调用execute()前自动校验参数——例如示例中的z.string().min(1)要求姓名非空,.email()校验邮箱格式,z.strictObject()拒绝未声明的多余字段。若参数校验不通过,框架会直接抛出NodeInputValidationError。同时,TypeScript 会根据该 Schema 自动推导execute({ input })中input的类型,而.describe()中的说明文字则会输出到自动生成的 Node 说明书中。execute({ input }):接收校验通过的参数并执行具体逻辑。将定义好的 Node 加入配置的nodes数组后,即可在 YAML 用例中调用(扩展已有工程时,将其追加到原有的nodes数组即可)。
2. 在 YAML 中调用
创建 cases/user.yaml 并调用刚刚注册的 user.create:
运行测试时,Midscene Test 会解析 YAML,找到名为 user.create 的 Node,并将 { name: "Alice", email: "alice@example.com" } 作为 input 传给 execute()。
示例中使用 async execute({ input }) 配合 await 等待文件写入完成;如果写入过程中抛出异常,该步骤即会标记为失败。
3. 生成 Node 说明书,验证注册结果
保存配置后,运行以下命令生成 Node 说明书:
该命令会加载项目配置,并在当前目录生成 midscene-node-spec.md(若配置了 projects,则会为每个执行项目生成 midscene-node-spec.<project-name>.md)。打开生成的说明书,确认其中包含以下内容:
- 可用 Node 列表(Available Nodes)中包含
user.create。 - 节点详情中包含“创建测试用户数据文件。”这一功能说明。
- 包含
name、email两个输入字段及其描述信息。
这一步仅用于验证 Node 是否成功注册、输入 Schema 是否可正常导出,不会实际执行 execute()。后续每当修改了 Node 定义或注册配置,都可以重新运行该命令更新说明书,供测试工程师和 AI Agent 查阅。详见生成当前项目的 Node 说明书。
为 Node 声明字符串简写
如果某个 Node 只有一个必填的字符串参数,可以通过 stringInputKey 指定字符串简写映射的目标字段名:
配置 stringInputKey: 'message' 后,在 YAML 中直接传入字符串即可映射到 input.message:
由于字符串简写只能提供一个字段的值,若 inputSchema 中还包含其他没有默认值的必填字段,简写调用仍会因缺少字段而校验失败。省略 stringInputKey 或将其设为 false 时,该 Node 不支持字符串简写,必须使用键值对对象传参。
Node 执行参数
Midscene Test 调用 execute() 时,会传入一个包含业务参数与运行上下文的参数对象,你可以通过 execute({ input, $, context }) 按需解构所需的字段。
业务参数与步骤配置
在参数对象中,input 存放由 Node inputSchema 校验后的业务参数,而 $ 存放由框架控制的步骤级配置(如超时时间、失败后是否继续执行)。
例如,在 YAML 中为 user.create 同时传入业务参数和 $ 配置:
框架会自动将 $ 从业务参数中剥离(因此 inputSchema 中无需声明 $),并将字段名转换为驼峰格式。在 execute({ input, $ }) 中可以分别读取到:
步骤超时与 continue-on-error 行为均由框架统一调度。注意,开启 continue-on-error 虽然允许当前阶段的后续步骤继续执行,但仍受资源安全限制约束,且该步 骤本身的失败依然会使整条用例最终标记为 failed。
可用的执行字段
execute() 接收的参数对象包含以下字段:
input:从 YAML 传入并经过 Zod 校验后的业务参数。$:由 Midscene Test 控制的步骤级配置(包含规范化后的timeoutMs和continueOnError)。signal:当步骤超时或整次运行被取消时触发的AbortSignal,可在异步请求或长耗时循环中用于响应取消。context:当前运行环境的上下文对象(若配置了fileSetup,则为fileSetup的返回值;否则为项目级setup的返回值)。onTeardown():注册当前 Node 创建资源的清理回调(根据所在阶段自动绑定到单次用例尝试或整个文件级别,按后进先出顺序执行)。scope:标识当前 Node 所处的执行范围,值为'case'或'document'。case/document:当前正在执行的用例或工作流文档的元信息。
其中,context 是跨步骤传递运行资源与业务状态的桥梁。下一节将通过完整示例演示如何利用 context 在多个 Node 之间共享数据。
跨 Node 共享上下文
每个 Node 的每次执行都是独立调用,框架不会把前一个步骤的返回值自动传给下一个步骤。当一条完整的测试链路需要由多个 Node 协作完成时(例如:先调用接口 创建测试订单 → 打开该订单的退款页面 → 测试结束后删除该测试订单),可以通过读写共享的 context 对象来传递数据。
创建共享上下文
下面的示例使用 fileSetup 为每份 YAML 文件创建独立的 Browser、Page 和 context 对象(由于没有跨文件共享的资源,这里无需配置项目级 setup;如需在多个文件间共享同一个 Browser,请参阅用 fileSetup 隔离资源)。
示例中的 orderService 代表你自己的测试数据服务(包含 create 和 remove 方法),实际使用时请将应用地址与服务实现替换为你的业务代码:
每份 YAML 文件都会获得一个独立的 Context 实例,其中的 orderId 字段将在后续步骤创建订单时写入。
在 Node 中写入数据并注册清理
接着在 nodes.ts 中定义 order.prepare:它调用 context.orderService.create() 创建测试订单,将生成的订单 ID 存入 context.orderId,并同时通过 onTeardown() 注册订单清理逻辑:
这里有两点值得注意:
- 显式写入
context:execute()返回的data仅用于记录到测试报告中,不会自动合并到context;必须显式执行context.orderId = order.id,后续步骤才能从context中读到该值。 - 创建与清理内聚:创建订单后立即调用
onTeardown()注册删除逻辑(闭包直接捕获本次生成的order.id)。这样一来,订单的创建与清理完全封装在order.prepare内部,YAML 用例无需再手动编写afterEach清理步骤。
在后续 Node 中读取数据
接着定义 browser.openRefundPage:它从 context.orderId 读取刚刚创建的订单 ID,并拼接 URL 打开对应的退款页面(若 orderId 为空则直接抛错):
在 midscene.config.ts 中将 fileSetup 与 refundNodes 一起导出:
现在,在 YAML 用例中只需在 beforeEach 调用 order.prepare,随后在 steps 中调用 browser.openRefundPage 即可:
在 beforeEach(或用例 steps)中通过 onTeardown() 注册的清理回调,会在当前用例的 afterEach 阶段结束后自动执行(即便 YAML 中未声明 afterEach 也会执行)。哪怕后续准备步骤或用例正文步骤失败,已注册的清理回调依然会运行;如果用例触发了重试,每次尝试都有各自独立的清理周期。
进阶用法
记录执行结果
在上面的示例中,order.prepare 返回了 { summary, data }:
summary(可选):简短的执行摘要文本,用于在测试报告中展示。data(可选):结构化的输出数据,会保存在运行结果文件中。
如果不需要在报告中记录额外输出,execute() 也可以不返回任何值。
异步操作、错误与取消
异步 Node 请使用 async execute() 并通过 await 等待异步任务完成;当业务校验或接口调用失败时,直接抛出 Error 即可使该步骤失败。
为了响应步骤超时或用户中断(如 Ctrl+C),Midscene Test 会在调用 execute() 时传入 signal(AbortSignal):
- 调用支持取消的底层 API 时,请将
signal透传进去,例如fetch(url, { signal })。 - 执行长耗时轮询或循环时,可在每次迭代间调用
signal.throwIfAborted()及时退出。
标记项目级共享资源故障:在开启文件并发且多个文件共享项目级资源(如共享同一个 Browser)时,如果 fileSetup 或某个 Node 确认该共享资源已 损坏,必须显式抛出带有 PROJECT_RESOURCE_ERROR 错误码的 WorkflowError(从 @midscene/test 导入,也可放入错误对象的 cause 或 AggregateError.errors 中),框架才会立即停止该项目下其余文件的执行。由于框架无法仅凭浏览器关闭的报错文本推断该 Browser 是文件独占还是项目共享,未标记 PROJECT_RESOURCE_ERROR 的报错默认仅按单文件失败处理,详见失败时如何执行。
资源的生命周期与清理
在 Node 内部通过 onTeardown() 注册的清理函数,其触发时机取决于该 Node 是在哪个阶段被调用的:
- 在
beforeEach、用例steps或afterEach中调用的 Node:清理函数在当次用例尝试(attempt)的afterEach结束后运行;每次失败重试都有独立的清理周期。 - 在
beforeAll或afterAll中调用的 Node:清理函数在当前 YAML 文件的afterAll结束后运行。
同一范围内的多个清理函数按后进先出(LIFO)顺序依次执行。注意,注册清理函数仅用于释放你在 Node 中创建的资源(或收尾日志/报告),不会让框架自动重建 Agent 或重置缓存。
关于异常情况下的清理保护机制:
- 等待超时操作退出:如果某个步骤因超时被取消、但底层异步操作尚未真正退出,框架会推迟执行清理函数,避免释放仍被占用的资源。
- 清理容错与超时:单个清理回调抛 错不会阻断后续清理回调的执行;若某个清理回调耗时过长,框架最多等待
teardownTimeout(默认 30 秒),超时后会将状态记录为pending并停止等待,而不会将其视为清理成功。 - 运行中断保护:即使整次测试运行被外部中断(如收到
SIGINT),框架仍会尽力执行afterEach、afterAll以及已注册的onTeardown()清理函数。
关于超时与取消的完整规则,请参阅超时与取消机制;项目级 Browser 与 Agent 的清理方式请参阅使用 setup 创建和清理共享资源。

