• 简体中文
  • 扩展和维护 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 未能正确生效

    在项目根目录下执行测试:

    pnpm exec midscene-test

    运行器会自动加载 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 是可选字段,我们强烈建议你声明此字段。

    1. 类型推导与校验:声明后,运行器会在进入 execute() 前自动执行 Zod 校验。若输入不匹配,将直接抛出 NodeInputValidationError 异常,并在编译期提供强类型推导(无需额外声明 TypeScript 接口)。
    2. 拒绝未知参数:推荐使用 z.strictObject()。若用例传入了多余的未知字段,运行器会及时拦截并报错。
    3. 说明书自动集成:字段上的 .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 属性(如规范化后的 timeoutcontinue-on-error)。
    • signal:超时或运行取消时触发的 AbortSignal,建议在内部异步请求或长耗时任务中使用它以实现提前优雅退出。
    • context:在 defineProjectSetup() 中返回并共享的项目级运行时资源。
    • history:深度只读、JSON 兼容的已运行 Node 历史。AI Agent 节点会自动读取并利用此字段理解上下文。
    • onTeardown():注册当前 Node 所创建资源的清理函数,支持 attempt 级别或 Document 级,按 LIFO(后进先出)顺序执行。
    • scope:标记当前 Node 执行的上下文边界,值为 casedocument
    • casedocument:当前执行位置的详细运行信息。

    多节点协作与状态共享

    在实际业务测试中,多个 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:aiActaiAssertrecordToReportlaunchwaitagent

    你可以通过调用 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 上的 titledescription 和 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],
    });

    关键配置策略

    1. 环境与资源隔离:每个 Execution Project 有其独立的 setup 运行环境(如启动单独的浏览器或绑定特定测试设备)。
    2. 多 Project 并发与生命周期槽test.maxConcurrency 参数控制同时活跃(Active)的 Project 数量(默认值为 1)。
      • 一个并发槽(slot)覆盖从 Project setup 开始到 teardown 完成的完整生命周期。
      • 单个 Project 内部,所有的工作流文档(Workflow Documents)、用例(Cases)和步骤(Steps)依然严格串行执行,以保证测试的确定性。
      • 当你需要同时驱动多台手机设备或多个浏览器实例时,应当创建多个不同的 projects 声明并增大并发数。
    3. 生命周期清理:使用 defineProjectSetup() 声明环境准备动作。利用 onTeardown() 注册销毁钩子,确保即便测试意外中断或运行中途出错,已获取的长生命周期资源也能按照后进先出的逆序被安全释放。

    编程式 API

    大多数团队只需使用项目配置、YAML 和 CLI。如果需要将运行器嵌入其他工具(如开发一个本地的 GUI 运行面板),可以使用以下导出的编程式 API:

    • loadTestProject():异步加载 midscene.config.ts 中的 TypeScript 项目配置(运行器不支持同步加载)。
    • runTestProject():异步发现、运行并汇总整个项目(从 @midscene/test/config 导出)。
    • CaseRunner / createCaseRunner():直接执行纯对象形式的单个用例(不含文件解析和生命周期控制)。
    • runWorkflowDocument():执行单个文档的完整生命周期及内部全部 Case。

    配置完成后,请继续阅读 编写和运行测试用例,熟悉具体的用例语法与参数。