扩展和维护 Test Runner
@midscene/test 支持注册业务 Node(节点)、管理运行资源和定义执行生命周期。框架维护者可以使用这些能力,接入浏览器、Agent、外部工具和业务接口,打造面向团队的定制化测试底座。
如果你想先了解整体设计,请阅读 Test Runner 概览。
快速开始
下面通过一个简单的示例项目,展示如何快速搭建一个测试项目,并在其中切换浏览器 UA 语言。
1. 安装依赖
创建一个空项目,并安装 Test Runner 的核心依赖及驱动工具:
pnpm add -D @midscene/test @midscene/web playwright
注意:在使用 Midscene Agent 之前,请先参考 模型配置 妥善设置相应的模型环境变量(如 API Key)。
2. 创建项目文件
建议采用以下基本目录结构:
team-test-runner/
├── cases/
│ └── midscene.yaml
└── midscene.config.ts
3. 配置 Test Project 与注册 Node
在项目根目录下创建 midscene.config.ts,这个配置文件负责注册可复用的 Node,并定义执行环境(Project):
import { defineNode, z } from '@midscene/test';
import {
defineProjectSetup,
defineTestProject,
} from '@midscene/test/config';
import { createMidsceneNodes } from '@midscene/test/midscene';
import { PlaywrightAgent } from '@midscene/web/playwright';
import { chromium, type Browser, type Page } from 'playwright';
interface ProjectContext {
browser: Browser;
page: Page;
agent?: PlaywrightAgent;
}
const geoInputSchema = z.strictObject({
latitude: z.number().describe('模拟位置的纬度。'),
longitude: z.number().describe('模拟位置的经度。'),
});
// 1. 注册自定义 Node:模拟地理位置 (GPS 定位)
const mockLocation = defineNode<
typeof geoInputSchema,
unknown,
ProjectContext
>({
name: 'browser.mockLocation',
title: '模拟地理位置',
description: '伪造浏览器的 GPS 地理位置定位。',
inputSchema: geoInputSchema,
async execute({ input, context }) {
const browserContext = context.page.context();
// 注入地理位置模拟权限并设置经纬度
await browserContext.grantPermissions(['geolocation']);
await browserContext.setGeolocation({
latitude: input.latitude,
longitude: input.longitude,
});
},
});
// 集成 Midscene 内置的 AI 能力节点(如 aiAct, aiAssert 等)
const midsceneNodes = createMidsceneNodes<ProjectContext>({
getAgent: ({ context }) => {
context.agent ??= new PlaywrightAgent(context.page);
return context.agent;
},
});
// 2. 声明浏览器环境的启动与清理
const playwrightSetup = defineProjectSetup<ProjectContext>({
name: 'playwright',
platform: 'web',
async setup({ onTeardown }) {
const browser = await chromium.launch({ headless: true });
onTeardown(() => browser.close());
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
return { browser, page };
},
});
// 3. 导出项目配置
export default defineTestProject<ProjectContext>({
projects: [
{
name: 'chromium',
platform: 'web',
setup: playwrightSetup,
files: { include: ['cases/**/*.{yaml,yml}'] },
},
],
nodes: [mockLocation, ...midsceneNodes],
});
4. 编写测试用例并运行
创建 cases/midscene.yaml 文件:
cases:
- name: 模拟地理位置并展示特定地区门店
tags: [smoke]
steps:
- browser.mockLocation:
latitude: 35.6762
longitude: 139.6503 # 模拟在东京 (Tokyo)
- launch:
uri: https://yoursite.com/stores
- aiAssert:
prompt: 页面上成功加载并展示了“东京”或其临近区域的门店推荐列表
message: 地理位置 Mock 未能正确生效
在项目根目录下执行测试:
运行器会自动加载 midscene.config.ts,查找满足条件的测试用例并执行。
注册自定义业务 Node
通过 defineNode(),你可以将复杂的接口调用、数据库操作、清理任务或特定的浏览器交互封装为具名 Node。封装后,用例作者可以直接在测试用例中调用它们。
基本业务 Node 示例
以下示例展示了如何封装一个通过 HTTP 接口创建测试订单的 Node:
import { defineNode, z } from '@midscene/test';
const orderInputSchema = z.strictObject({
sku: z.string().min(1).describe('要下单的商品 SKU。'),
quantity: z.number().int().positive().describe('购买数量。'),
});
interface ProjectContext {
apiBaseUrl: string;
}
const createOrder = defineNode<
typeof orderInputSchema,
{ id: string },
ProjectContext
>({
name: 'order.create',
title: '创建订单',
description: '通过测试接口创建一个商品订单。',
inputSchema: orderInputSchema,
async execute({ input, context, signal }) {
const response = await fetch(`${context.apiBaseUrl}/test/orders`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
sku: input.sku,
quantity: input.quantity,
}),
signal,
});
if (!response.ok) {
throw new Error(`创建订单失败:HTTP ${response.status}`);
}
const order = (await response.json()) as { id: string };
return {
summary: `已创建订单 ${order.id}`,
data: order,
};
},
});
把 Node 加入到配置文件中的 nodes 数组后,用例作者就可以在用例中直接消费它:
cases:
- name: 下单流程测试
steps:
- order.create:
sku: midscene-mug
quantity: 1
使用 Zod 声明输入与强校验
inputSchema 是可选字段,我们强烈建议你声明此字段。
- 类型推导与校验:声明后,运行器会在进入
execute() 前自动执行 Zod 校验。若输入不匹配,将直接抛出 NodeInputValidationError 异常,并在编译期提供强类型推导(无需额外声明 TypeScript 接口)。
- 拒绝未知参数:推荐使用
z.strictObject()。若用例传入了多余的未知字段,运行器会及时拦截并报错。
- 说明书自动集成:字段上的
.describe() 信息会直接编译进自动生成的 Node 说明书,作为 AI Agent 或人类用例编写者的参考手册。
const refundInputSchema = z.strictObject({
orderId: z.string().min(1).describe('需要退款的订单 ID。'),
reason: z.string().optional().describe('退款原因。'),
});
const refundOrder = defineNode({
name: 'order.refund',
description: '为已有订单发起退款。',
inputSchema: refundInputSchema,
async execute({ input }) {
// input 会被推导为 { orderId: string; reason?: string }
await refund(input.orderId, input.reason);
},
});
Node 执行上下文环境
execute(ctx) 接收的 ctx 包含以下常用字段:
input:从 YAML 传入并经过 Zod 校验后的业务参数。
$:由运行器控制的通用 Step 属性(如规范化后的 timeout 和 continue-on-error)。
signal:超时或运行取消时触发的 AbortSignal,建议在内部异步请求或长耗时任务中使用它以实现提前优雅退出。
context:在 defineProjectSetup() 中返回并共享的项目级运行时资源。
history:深度只读、JSON 兼容的已运行 Node 历史。AI Agent 节点会自动读取并利用此字段理解上下文。
onTeardown():注册当前 Node 所创建资源的清理函数,支持 attempt 级别或 Document 级,按 LIFO(后进先出)顺序执行。
scope:标记当前 Node 执行的上下文边界,值为 case 或 document。
case 或 document:当前执行位置的详细运行信息。
多节点协作与状态共享
在实际业务测试中,多个 Node 常常需要共享上下文状态。例如,订单退款用例需要在 beforeEach 中创建订单并记录订单 ID,在 steps 中访问该 ID 进行退款,最后在 afterEach 中进行数据清理。
通过在自定义 ProjectContext 中定义状态属性,可以轻松实现这种协作:
import { defineNode, z } from '@midscene/test';
import type { PlaywrightAgent } from '@midscene/web/playwright';
import type { Page } from 'playwright';
interface ProjectContext {
agent: PlaywrightAgent;
appBaseUrl: string;
page: Page;
orderId?: string; // 用于在 Node 之间共享测试状态
orderService: {
create(input: { status: 'paid' }): Promise<{ id: string }>;
remove(orderId: string): Promise<void>;
};
}
const emptyInputSchema = z.strictObject({});
const prepareOrderInputSchema = z.strictObject({
status: z.literal('paid').describe('待创建订单的状态。'),
});
const getOrderId = (context: ProjectContext) => {
if (!context.orderId) {
throw new Error('测试订单尚未创建。');
}
return context.orderId;
};
// 1. 准备订单环境
const prepareOrder = defineNode<
typeof prepareOrderInputSchema,
{ orderId: string },
ProjectContext
>({
name: 'order.prepare',
description: '调用订单服务创建测试订单。',
inputSchema: prepareOrderInputSchema,
async execute({ input, context }) {
const order = await context.orderService.create(input);
context.orderId = order.id; // 将 ID 保存至上下文
return {
summary: `已创建测试订单 ${order.id}`,
data: { orderId: order.id },
};
},
});
// 2. 访问已保存的订单状态
const openRefundPage = defineNode<
typeof emptyInputSchema,
unknown,
ProjectContext
>({
name: 'browser.openRefundPage',
description: '打开当前测试订单的退款页面。',
inputSchema: emptyInputSchema,
async execute({ context }) {
const orderId = getOrderId(context);
await context.page.goto(`${context.appBaseUrl}/orders/${orderId}/refund`);
},
});
// 3. 业务环境清理
const cleanupOrder = defineNode<
typeof emptyInputSchema,
unknown,
ProjectContext
>({
name: 'order.cleanup',
description: '删除当前测试订单。',
inputSchema: emptyInputSchema,
async execute({ context }) {
const orderId = getOrderId(context);
await context.orderService.remove(orderId);
delete context.orderId; // 恢复上下文干净状态
},
});
export const refundNodes = [prepareOrder, openRefundPage, cleanupOrder];
集成 Midscene Agent
@midscene/test/midscene 导出了六个系统内置 Node:aiAct、aiAssert、recordToReport、launch、wait 和 agent。
你可以通过调用 createMidsceneNodes() 并传入 getAgent 回调,将其方便地集成到你的测试运行器配置中:
import { createMidsceneNodes } from '@midscene/test/midscene';
const midsceneNodes = createMidsceneNodes<ProjectContext>({
getAgent: ({ context }) => {
context.agent ??= new PlaywrightAgent(context.page);
return context.agent;
},
});
一键生成 Node 说明书
为了让测试用例编写者(包括 AI Agent)清晰、直观地检索当前项目注册了哪些 Node 及其参数规范,运行器提供了 describe-nodes 描述工具。它会自动将 Node 上的 title、description 和 Zod inputSchema 自动编译生成一份标准的 Markdown 说明书文档。
执行以下命令直接生成团队专属说明书:
pnpm exec midscene-test describe-nodes > midscene-nodes.md
或者指定测试目录或专属配置文件:
pnpm exec midscene-test describe-nodes ./e2e --config ./config/midscene.config.ts
生成的说明书包含当前 Test Project 实际注册的全部 Node(包含 createMidsceneNodes() 返回的六个系统内置 Node),按名称排序。运行器会自动将 Zod inputSchema 转换为标准的 JSON Schema。
配置和管理 Test Project
defineTestProject() 是测试底座的核心配置入口,支持声明一个或多个 Execution Project:
export default defineTestProject<ProjectContext>({
projects: [
{
name: 'android-smoke',
platform: 'android',
setup: doraAndroidSetup,
files: {
include: ['cases/**/*.{yaml,yml}'],
exclude: ['cases/**/*.draft.yaml'],
},
tags: { include: ['smoke'], exclude: ['manual'] },
retry: 1,
variables: { appUri: 'com.example.app' },
},
],
test: {
maxConcurrency: 1, // 控制 active 的 Execution Project 的最大并发数
bail: 0, // 失败阈值拦截。配置为 > 0 时,达到失败用例数会自动停止新任务调度
testTimeout: 120_000,
},
output: {
reportDir: './midscene_run/report',
},
nodes: [createOrder, ...midsceneNodes],
});
关键配置策略
- 环境与资源隔离:每个 Execution Project 有其独立的
setup 运行环境(如启动单独的浏览器或绑定特定测试设备)。
- 多 Project 并发与生命周期槽:
test.maxConcurrency 参数控制同时活跃(Active)的 Project 数量(默认值为 1)。
- 一个并发槽(slot)覆盖从 Project setup 开始到 teardown 完成的完整生命周期。
- 单个 Project 内部,所有的工作流文档(Workflow Documents)、用例(Cases)和步骤(Steps)依然严格串行执行,以保证测试的确定性。
- 当你需要同时驱动多台手机设备或多个浏览器实例时,应当创建多个不同的
projects 声明并增大并发数。
- 生命周期清理:使用
defineProjectSetup() 声明环境准备动作。利用 onTeardown() 注册销毁钩子,确保即便测试意外中断或运行中途出错,已获取的长生命周期资源也能按照后进先出的逆序被安全释放。
编程式 API
大多数团队只需使用项目配置、YAML 和 CLI。如果需要将运行器嵌入其他工具(如开发一个本地的 GUI 运行面板),可以使用以下导出的编程式 API:
loadTestProject():异步加载 midscene.config.ts 中的 TypeScript 项目配置(运行器不支持同步加载)。
runTestProject():异步发现、运行并汇总整个项目(从 @midscene/test/config 导出)。
CaseRunner / createCaseRunner():直接执行纯对象形式的单个用例(不含文件解析和生命周期控制)。
runWorkflowDocument():执行单个文档的完整生命周期及内部全部 Case。
配置完成后,请继续阅读 编写和运行测试用例,熟悉具体的用例语法与参数。