首页
/ Playwright 组件测试实战指南:用内置 mount 夹具驱动 Story Gallery

Playwright 组件测试实战指南:用内置 mount 夹具驱动 Story Gallery

2026-09-06 15:28:13作者:翟萌耘Ralph

本篇指南基于 Playwright 官方文档 Component testing,系统讲解 Playwright Test 的组件测试(Component Testing)方法论:通过 @playwright/test 内置的 mount 夹具,将组件挂载到你自己的 dev server 提供的 story gallery 页面上进行隔离测试。读完本文,你将掌握 story/gallery 模型的全部约定、playwright.config.ts 的完整配置方式、状态录制与 update() 等测试模式,以及从已停更的 @playwright/experimental-ct-* 实验包的完整迁移路径,并能直接在自己的 React 或 Vue 项目中落地组件级测试。

核心模型:story、gallery 与 mount 夹具

Playwright Test 可以隔离地测试 Web 应用的组件。一个组件测试本质上就是一条普通的 Playwright 端到端测试,运行在你自己的 dev server 提供的一个小型 story gallery 页面上。没有专门的组件测试运行时、没有打包器集成、没有额外的 npm 包——一切都由 @playwright/test 内置的 mount 夹具驱动:

import { test, expect } from '@playwright/test';

test('click should expand', async ({ mount }) => {
  const component = await mount('components/Expandable/Stateful');
  await component.getByRole('button').click();
  await expect(component.getByTestId('expanded')).toHaveValue('true');
});

测试运行在 Node.js 中,而组件运行在真实浏览器里:触发的是真实点击、执行的是真实布局、可以做视觉回归测试。同时测试获得 Playwright Test 的全部能力:并行、参数化、重试与事后追踪(post-mortem tracing)。

注意:实验性包 @playwright/experimental-ct-react-ct-react17-ct-vue 已被移除且不再发布。如果仍在使用它们,请停留在 Playwright 1.62,并参照本文迁移章节完成迁移。

为什么是框架无关的设计

实验包允许测试内联书写 JSX——mount(<Button onClick={spy} />)。为此 Playwright 必须接管整条管线:扫描测试中的组件、用自带的 Vite 副本和自己的配置编译 bundle、从自己的 server 提供服务,并在 Node.js/浏览器边界上传递 props 与回调。这个设计让实验包永远停留在实验状态:

  • 只有你的构建配置恰好与它一致时才可用。 路径别名、插件、CSS 处理都必须手动镜像进 ctViteConfig。使用 webpack、Next.js 或自定义管线的项目根本无法用自己的构建。每个框架都需要自己的包和自己的运行时胶水代码,每新增一个框架就多一个包。
  • Node.js/浏览器边界会泄漏。 测试里写的 JSX 在 Node.js 中编译后又在浏览器中重组,活对象无法跨越边界,回调只能通过 marshalling 半工作,模块 mock 会静默失效。

新的方案反转了控制方向:

  • 管线归你所有。 组件由你自己的 dev server 构建和服务,带着你的插件、别名和 CSS。Playwright 不编译也不服务任何东西——它只是导航到一个页面,和其他任何测试一样。
  • 框架无关。 唯一的框架相关部分是 gallery 页面——一个由你拥有的一小段模块。React、Vue、Svelte、Solid 或任何其他框架:只要你的 dev server 能渲染它,Playwright 就能测试它。
  • 稳定。 测试从普通的 @playwright/test 导入 testexpectmount 是文档化的内置夹具。没有实验包可依赖,也没有第二套配置方言。

三个概念构成了整个模型:

  • story 是一个小型包装组件,把被测组件嵌入某个特定场景中:硬编码 props、mock 数据、providers、记录型回调。story 与组件同目录存放,文件名为 *.story.tsx(或 .ts/.jsx/.js/.vue);每个具名导出就是一个 story。
  • gallery 是一个单页面,由你的 dev server 服务,暴露 window.mount(params)window.unmount() 两个函数,把按 story id 解析出来的 story 渲染到 #root 元素中。它框架相关,由你实现和维护。
  • mount 夹具导航到 gallery(即 baseURL),用 story id 和 props 调用 window.mount(),并返回指向 gallery 根的 Locator。从它出发限定查询范围:component.getByRole('button').click()

组件所需的一切都在 story 内部(运行在浏览器里)完成初始化;测试断言的一切都必须能透过页面观察到:DOM、URL、网络。

快速上手(五步)

第 1 步:让编码代理读取 skill 完成搭建

gallery 属于应用代码——它归你所有,不属于 Playwright。最快的拥有方式是不自己写:Playwright 把这套方法论完整打包为一个 agent skill(源码见 SKILL.md)。安装 skill 并让你的编码代理(Claude Code、GitHub Copilot 或类似工具)完成搭建:

npx playwright init-skills
Set up component testing using the playwright-component-testing skill.

代理会检测你的框架和打包器、为你的技术栈实现 gallery、在配置中添加一个 Playwright project,并写出第一个 story 和 spec。该 CLI 命令在 program.ts 中注册,还支持 --loop <loop> 选项(可选 claudeagents,默认 claude)来选择 agentic loop 提供方。

即使你永远不打开 gallery 文件,也值得知道它必须履行的契约:

  • 它是 playwright/gallery/ 下的一个单页面,由你自己的 dev server 服务——Vite 应用直接用已有 dev server 服务(Vite 会服务项目根下任意 .html 文件,vite build 会忽略它);其他环境则运行一个独立的小型 Vite server。
  • 它发现你的 *.story.* 文件并暴露两个函数:window.mount({ story, props }) 把给定 id 的 story 渲染进 #rootwindow.unmount() 将其卸载。未知 story 或渲染错误会使 Promise reject,表现为测试中 mount() 调用抛出异常。
  • 它在多次调用间复用渲染根,因此 component.update(props) 是协调(reconcile)而非重新挂载,组件内部状态得以保留。
  • 它以与应用入口相同的方式导入全局 CSS;window.mount 函数体就是应用级全局设置的天然位置——相当于旧 beforeMount/afterMount 钩子。

如果偏好手写,已安装的 skill 中 references/gallery-spec.md 包含完整规范与可运行的 React、Vue 示例——整个页面不过几十行。

第 2 步:配置 Playwright

playwright.config.ts 中添加一个 project,并把 baseURL 指向 gallery:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'components',
      testDir: './tests/components',
      use: {
        ...devices['Desktop Chrome'],
        baseURL: 'http://localhost:5173/playwright/gallery/index.html',
        serviceWorkers: 'block',
        reuseContext: true,
      },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173/playwright/gallery/index.html',
    reuseExistingServer: !process.env.CI,
  },
});

mount 会导航到 baseURL,所以它必须指向 gallery。serviceWorkers: 'block' 防止应用自己的 service worker 提供缓存响应、从而遮蔽你的 page.route() mock。reuseContext: true 让同一 worker 内的多个测试复用浏览器上下文——对组件测试套件是显著提速,也是实验包曾经隐式应用的同一优化。如果配置中已有 projects/webServer,合并而不是替换。

第 3 步:编写 story

story 与它驱动的组件同目录存放,每个具名导出是一个场景:

import { Button } from './Button';

export const Primary = () => <Button title='Submit' />;

export const Disabled = () => <Button title='Submit' disabled />;

第 4 步:编写测试

import { test, expect } from '@playwright/test';

test('renders primary button', async ({ mount }) => {
  const component = await mount('components/Button/Primary');
  await expect(component.getByRole('button')).toHaveText('Submit');
});

test('disabled button is disabled', async ({ mount }) => {
  const component = await mount('components/Button/Disabled');
  await expect(component.getByRole('button')).toBeDisabled();
});

第 5 步:运行

npx playwright test --project=components

mount 夹具的实现细节(源码级)

packages/playwright/src/index.ts 可以看到 mount 夹具的完整运行时实现,它只有三件核心的事:

mount: async ({ page, baseURL }, use) => {
  // exposeFunctions turns any callbacks in props into real, browser-callable
  // functions that dispatch back to the test.
  const callMount = (params: { story: string, props?: Record<string, any> }) =>
    page.evaluate(async p => {
      const w = window as any;
      if (typeof w.mount !== 'function')
        throw new Error('The gallery page does not define window.mount().');
      await w.mount(p);
    }, params, { exposeFunctions: true });
  await use(async (storyId: string, props?: any) => {
    if (!baseURL)
      throw new Error('mount() requires `baseURL` to point at the component gallery. Set it in your Playwright config.');
    // The gallery is a single page (served at baseURL) that exposes window.mount()/window.unmount().
    await page.goto(baseURL);
    await callMount({ story: storyId, props });
    // Points at the gallery root, scope the queries: component.getByRole(...).
    return Object.assign(page.locator('#root'), {
      // update() re-renders the same story with new props without navigating; ...
      update: (newProps?: any) => callMount({ story: storyId, props: newProps }),
      unmount: () => page.evaluate(async () => {
        await (window as any).unmount?.();
      }),
    });
  });
},

要点印证了文档的每一条承诺:

  1. 未设置 baseURL 会直接抛错mount() requires baseURL to point at the component gallery),因此第 2 步的配置不是可选的。
  2. 每次 mount() 都执行 page.goto(baseURL)——这就是"每次 mount 全新导航、测试完全隔离"的实现来源。
  3. window.mount 通过 page.evaluate 调用,并传入 { story, props } 参数;gallery 页若未定义 window.mount() 会得到明确错误信息。
  4. 返回值是 #root 的 Locator,并用 Object.assign 附加了 update(props)(不导航、用新 props 再次调用 window.mount)与 unmount()(调用 window.unmount())两个方法。update() 能否保留状态取决于 gallery 是否复用渲染根——这正是 gallery 契约的要求。
  5. 值得注意的是 callMount 使用了 exposeFunctions: true,从源码注释看,这会把 props 中的回调转换为可回调到测试的浏览器函数——但文档仍强烈建议 props 保持为纯可序列化数据,回调归 story 所有。

类型定义在 packages/playwright/types/test.d.ts 中,mount 是泛型的:mount<Story = never, Id extends StoryId>(storyId: Id, props?: MountProps<Story, Id>),配合类型工具 StoryProps(从函数组件签名或 Vue 的 $props 中推断 props 类型)实现 props 的编译期校验。它承诺返回 Promise<Locator & { update(props?): Promise<void>, unmount(): Promise<void> }>

Gallery 契约要点

references/gallery-spec.md 给出了契约的精确定义,值得逐条对照:

  • window.mount(params) 接收 { story, props },把解析后的组件渲染进 #root;返回的 Promise 在挂载完成时 resolve、失败时 reject(未知 story、渲染抛错)。reject 直接表现为测试中 await mount(...) 抛出带真实堆栈的异常——没有 HTTP 状态码或 DOM 属性信号。
  • 跨调用复用根update() 会带着相同 story 和新 props 再次调用 window.mount(不导航)。只有渲染进同一个 root/实例,框架才会协调、保留组件内部状态;每次调用重建根(或导航)都会重置状态。框架自行协调,仅在 story(组件类型)变化时重新挂载。
  • window.mount 就是你的 setup/teardown 钩子:它是浏览器侧的 beforeMount/afterMount 等价物——安装 providers、seed store、启动 in-browser mock server 放在渲染前,渲染后工作放在渲染后,全部在这个函数里按 story/props 分支。
  • story id 语法(推荐)src/ 下的路径去掉 .story.* 扩展名 + 导出名,如 src/components/Button.story.tsx 的导出 Primarycomponents/Button/Primary;任意唯一的尾部后缀也可解析(Button/Primary);单文件组件 story(Button.story.vue)是一个 story,仅用路径寻址(其 default 导出)。
  • Vue 变体createApp(...).mount() 每次调用都构造新实例,因此正确做法是挂载一个小型"响应式宿主"一次、之后只更新它的 refs——ref 更新会在原地重渲染,这正是跨 update() 保留状态的关键。

规范的 React + Vite 参考实现(仅供说明契约,并非让你照抄的文件):

// playwright/gallery/main.tsx
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';

const stories = import.meta.glob('../../src/**/*.story.{tsx,jsx}');
const id = (f: string) => f.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');

async function resolve(storyId: string) {
  const sep = storyId.lastIndexOf('/');
  const [path, name] = [storyId.slice(0, sep), storyId.slice(sep + 1)];
  const file = Object.keys(stories).find(f => id(f) === path || id(f).endsWith('/' + path));
  const mod = (file && await stories[file]()) as Record<string, any> | undefined;
  return mod?.[name] ?? mod?.default;
}

const rootEl = document.getElementById('root')!;
let root: Root | undefined;

(window as any).mount = async ({ story, props }: { story: string, props?: Record<string, any> }) => {
  const Story = await resolve(story);
  if (!Story)
    throw new Error(`Unknown story: ${story}`);
  root ??= createRoot(rootEl);   // 复用根,让 update() 协调并保留状态
  // flushSync 保证渲染错误 reject promise 而不是被吞掉
  flushSync(() => root!.render(<Story {...props} />));
};

(window as any).unmount = async () => {
  root?.unmount();
  root = undefined;
};
<!-- playwright/gallery/index.html -->
<!DOCTYPE html>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>

Story 作为一种方法论

story 不只是测试技巧——它们是可 grep、可评审的组件状态文档,以下约定让它们保持如此:

  • 一个导出对应一个场景。 优先新增 story 导出而非参数化已有 story。Button.story.tsx 导出 PrimaryDisabledWithLongTitle 读起来就像一份组件规格说明。
  • story 与组件同目录。 src/components/Button.story.tsx 文档化了 src/components/Button.tsx。重命名和重构同时触及两者。
  • story id 从文件路径派生src/ 下的路径去掉 .story.* 扩展名,加上导出名——components/Button/Primary。任意唯一后缀也可以:mount('Button/Primary')
  • story 拥有组件所需的一切:providers、mock 数据、状态、回调。测试拥有的只有交互和断言。

由于每个 story 都是一个具名、可寻址的页面状态,gallery 同时充当"活目录":在浏览器中打开 gallery URL 即可渲染任意 story 用肉眼检查。

测试模式

记录状态以供断言

组件接受回调,而测试想断言回调被触发。与其在 Node.js 和浏览器之间 marshalling 回调,不如让 story 拥有状态并提供回调——把可观察的结果记录到组件旁边的一个隐藏表单里:

React 版本([Button.story 同款写法]):

import { useState } from 'react';
import { Expandable } from './Expandable';

export const Stateful = () => {
  const [expanded, setExpanded] = useState(false);
  return <>
    <Expandable expanded={expanded} setExpanded={setExpanded} title='Title'>Details</Expandable>
    <form hidden><input data-testid='expanded' readOnly value={String(expanded)} /></form>
  </>;
};

Vue 版本(render 函数风格):

import { defineComponent, h, ref } from 'vue';
import Expandable from './Expandable.vue';

export const Stateful = defineComponent(() => {
  const expanded = ref(false);
  return () => h('div', [
    h(Expandable, {
      'expanded': expanded.value,
      'onUpdate:expanded': (value: boolean) => expanded.value = value,
      'title': 'Title',
    }),
    h('form', { hidden: true }, [
      h('input', { 'data-testid': 'expanded', 'readonly': true, 'value': String(expanded.value) }),
    ]),
  ]);
});
test('click should expand', async ({ mount }) => {
  const component = await mount('components/Expandable/Stateful');
  await component.getByRole('button').click();
  await expect(component.getByTestId('expanded')).toHaveValue('true');
});

这个模式是整套方法论的核心:

  • 整个场景在浏览器内运行——没有回调 marshalling,没有 Node.js/浏览器边界可泄漏。
  • toHaveValue() 是 web-first 断言:它会重试直到状态落地,无需手动 await 或轮询。
  • 每个观察值记录在自己的 data-testid input 中——标量用 String(...),payload 用 JSON.stringify(...)。反方向同样适用:执行操作,然后断言值没有变化。
  • 记录的状态在打开 gallery 查看 story 时可见:手动点击组件,观察值变化——story 同时就是自动化测试覆盖的同一场景的手动测试页。保持表单 hidden 以获得干净的截图基线,或开发期间去掉 hidden 属性实时查看组件旁的状态。

每测试 props

当场景确实需要参数化时,把纯可序列化的 props 作为 mount 的第二个参数传入,gallery 会把它作为 props 交给 story:

import { Button } from './Button';

export const WithTitle = ({ title = 'Default' }: { title?: string }) =>
  <Button title={title} />;
import type { WithTitle } from '../../src/components/Button.story';

const component = await mount<typeof WithTitle>('Button/WithTitle', { title: 'Hello' });

mount 对 story 是泛型的:把 story 类型作为模板参数传入,props(以及 update())就会按 story 签名做类型检查(对应类型定义中的 MountProps<Story, Id> 工具类型,见 test.d.ts)。props 保持纯可序列化数据——回调归 story 所有。

用 update() 测试 prop 过渡

要测试组件对 prop 变化不重挂载(状态保留)的反应,调用 component.update(newProps)。它在既有根上用新 props 重渲染同一个 story:

const component = await mount('components/Counter/Default', { value: 1 });
await expect(component.getByTestId('value')).toHaveText('1');
await component.update({ value: 2 });
await expect(component.getByTestId('value')).toHaveText('2');

index.ts 的实现看,update 就是用相同 story 和新 props 再次调用 window.mount、不做任何导航;能否保留状态取决于 gallery 是否按契约复用了渲染根。

多状态与视觉对比

每次 mount() 都全新导航,因此测试完全隔离,一个测试中挂载多个 story 的代价很低:

await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png');
await expect(await mount('Button/Disabled')).toHaveScreenshot('disabled.png');

对返回的根 locator 截图(而不是整页),避免把 gallery 里可能放的其他东西纳入断言。

处理网络请求

照常使用 page.route()——在 mount() 之前注册路由,因为挂载会触发导航:

test('renders the error state', async ({ page, mount }) => {
  await page.route('**/api/items', route => route.fulfill({ status: 500 }));
  const component = await mount('components/ItemList/Default');
  await expect(component.getByRole('alert')).toContainText('Something went wrong');
});

配置中的 serviceWorkers: 'block' 选项防止应用自己的 service worker 提供遮蔽路由的缓存响应。拥有 MSW handler 库的团队可以在 story 或装饰器中启动 worker 替代这一方案。

调试 story

在浏览器中打开 gallery URL,从 DevTools 控制台调用 await window.mount({ story: 'components/Button/Primary' })——这正是 mount 夹具做的事(page.goto(baseURL) + window.mount(...))。未知 story 或渲染错误会 reject window.mount,表现为测试的 mount() 抛出带真实堆栈的异常。若想不用控制台浏览,可以给 gallery 加一个可选的索引页列出所有发现的 story。

迁移 from 实验性包

实验包在测试文件中编译 JSX 再把它 marshalling 进浏览器;gallery 模式把场景移入在浏览器中原生运行的 story 导出。概念映射如下(详见 skill 内的 migration.md):

@playwright/experimental-ct-* Story gallery
mount(<Button onClick={spy} />) 有状态 story:story 提供 onClick 并把效果记录到隐藏 input;测试用 toHaveValue() 断言
从测试传普通数据 props 精神上不变:mount(id, props)
从测试传 JSX children / slots 每种组合一个 story 导出(Vue:slot 重的场景用 .story.vue 文件)
component.update(<Button count={2} />) component.update({ count: 2 })
component.unmount() component.unmount()
beforeMount / afterMount 钩子 gallery 的 window.mount 函数体(全局),或 story 装饰器(按 story)
hooksConfig 按测试变体 props:mount('App/Routing', { route: '/dashboard' }),由 story 解释
router 夹具 / Node.js 中的 MSW handlers 测试中的 page.route(),或 story 内的 MSW setupWorker
playwright/index.html(样式、主题) gallery 的 index.html 与入口模块导入
ctViteConfigctPortctTemplateDir 移除——gallery 经由你自己的 dev server 运行;端口在 webServerbaseURL
ct 包的 defineConfig @playwright/test 的普通 defineConfig

一个典型 spec 的迁移对照:

import { test, expect } from '@playwright/experimental-ct-react';
import Button from '../src/components/Button';

test('counts clicks', async ({ mount }) => {
  let clicks = 0;
  const component = await mount(<Button title='Submit' onClick={() => ++clicks} />);
  await component.getByRole('button').click();
  expect(clicks).toBe(1);
});
import { useState } from 'react';
import { Button } from './Button';

export const CountsClicks = () => {
  const [clicks, setClicks] = useState(0);
  return <>
    <Button title='Submit' onClick={() => setClicks(count => count + 1)} />
    <form hidden><input data-testid='click-count' readOnly value={String(clicks)} /></form>
  </>;
};
import { test, expect } from '@playwright/test';

test('counts clicks', async ({ mount }) => {
  const component = await mount('components/Button/CountsClicks');
  await component.getByRole('button').click();
  await expect(component.getByTestId('click-count')).toHaveValue('1');
});

增量迁移:在锁定 Playwright 1.62 期间,与旧的 CT project 并列搭建 gallery 和 components project,逐个 spec 迁移,然后连同 playwright/index.htmlplaywright/index.tsplaywright/.cache 一起移除 @playwright/experimental-ct-* 依赖并升级。

需要注意的事项:

  • story id 是字符串。 重命名或移动 story 在运行时而非编译期破坏 spec。使用 mount<typeof Story> 至少能在编译期把 props 绑定到 story。
  • 每测试 JSX 消失了。 每个测试构造不同 JSX 树的测试,变为每种组合一个 story 导出——这正是设计意图:每个值得测试的组合都值得被命名和评审。

常见问题

如何访问组件的方法或其实例?

在测试代码中访问组件的内部方法或实例既不被推荐也不被支持。应聚焦于从用户视角观察和交互:点击它、查看页面、通过 story 把内部效果记录进 DOM。当测试避免实现细节时,变得更不脆弱、更有价值。如果从用户视角运行的测试失败了,通常意味着自动化测试发现了一个真实的 bug。

能保留打包器插件、别名和 CSS 配置吗?

能——这正是设计的核心。gallery 由你自己的 dev server 服务,所以你的应用能渲染什么,你的 story 就能渲染什么。没有需要同步的第二份打包器配置。

除 React 和 Vue 之外的框架怎么办?

让你的编码代理为你的框架实现 gallery 契约:把 story id 解析为组件、渲染进 #root、跨调用复用根使 update() 保留状态。mount 夹具不关心也不需要对端是哪个框架。

参考文件

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388