首页
/ Playwright 组件测试:用 Story Gallery 模式替代独立组件测试运行时

Playwright 组件测试:用 Story Gallery 模式替代独立组件测试运行时

2026-09-06 18:10:35作者:温玫谨Lighthearted

组件测试的常规路径需要专门的组件测试运行器(如 @playwright/experimental-ct-react),它编译测试文件中的 JSX 并将其"序列化搬运"到浏览器。Playwright 在移除这套实验性运行时后,给出了一条更轻的替代路线:用普通 e2e 测试 + 一个由应用自带 dev server 托管的 Story Gallery 页面来测试 React / Vue 组件。本指南以仓库内 SKILL.md 为骨架,结合 Gallery 契约、框架细则与 mount fixture 源码,讲解如何搭建 gallery、编写 story 与 spec、驱动 mount/update/unmount,并实现类型安全与网络 Mock——读完你可以在自己的 Vite/非 Vite 项目中落地整套组件测试方案,或完成从 @playwright/experimental-ct-* 的平滑迁移。

核心概念:story、gallery 与内置 mount fixture

整套方案的三个支柱缺一不可:

  • story(故事):一个"微型包装组件",把被测组件嵌入某个具体场景——写死的 props、mock 数据、全局 provider、以及"记录型"回调。story 与被测组件放在一起,命名形如 *.story.tsx(也支持 .ts/.jsx/.js/.vue),每个具名导出就是一个 story。
  • gallery(画廊):你自己实现并拥有的单个页面,按 gallery-spec.md 定义的契约暴露 window.mount(params) / window.unmount(),负责从 story 文件(例如用 import.meta.glob)解析出目标 story 并渲染到 #root。它是唯一与框架耦合的胶水层,没有现成模板可抄。
  • mount fixture@playwright/test 内置的测试 fixture。它在测试里驱动 gallery 的 window.mount,返回指向 gallery 根节点(#root)的 Locator。无需额外脚手架。

三条贯穿始终的原则(来自 SKILL.md):

  1. 组件需要的一切依赖都必须在 story 内部准备好——story 运行在浏览器里;
  2. 测试要断言的一切都必须能 透过页面观察到——DOM、URL、网络;
  3. 组件接收回调时,由 story 创建状态、接上回调、把状态记录进一个隐藏表单,测试再对记录值断言。

fixture 实现 可以印证这一契约的底层行为:mount 内部先 page.goto(baseURL) 导航到 gallery,再通过 page.evaluate 调用页面的 window.mount,随后返回 page.locator('#root'),并在其上附加两个扩展方法——update(newProps)(不导航、原地以新 props 重渲染同一 story)和 unmount()(调用页面的 window.unmount)。因此 SKILL.md 反复强调:查询必须从 component 这个根 Locator 出发component.getByRole('button').click()),而不是 component.click()——因为 #root 下面才是组件本体。

前提:这项技能在仓库中的定位

SKILL.md 位于 packages/playwright-core/src/tools/skills/playwright-component-testing/,其元数据描述表明它用于"用 Playwright 隔离测试 React/Vue 组件"或"从 @playwright/experimental-ct-react / -vue 迁移"。迁移文档同时明确:这两个 CT 包已在 Playwright 1.63 中移除且不再发布,因此 gallery 模式是当前的官方演进方向。mount fixture、reuseContext worker 级复用选项都已并入 packages/playwright/src/index.tsmount 见 502-528 行,reuseContext 选项见 459 行),是 @playwright/test 的原生能力而非附加包。

Setup Workflow:六步搭好组件测试

SKILL.md 给出从零起步的完整流程,核心是:先探测框架与打包器,决定 gallery 由谁托管

第一步:判断框架与托管方式

  • React vs Vue:决定后续参考的框架笔记与 story 示例形态。
  • 应用跑在 Vite 上(存在 vite.config.*):gallery 直接由现有 dev server 在 /playwright/gallery/index.html 提供服务。Vite 会为项目根目录下的任意 .html 提供托管,应用的插件/别名/CSS 自动生效,且 vite build 会忽略该页面——无需额外 server
  • 其他情况(Next.js、webpack、没有 dev server):用一个独立的迷你 dev server(例如 Vite)专门托管 gallery 页,并把 baseURL 指向它。这要求把 vite 与对应的框架插件加入 devDependencies。

第二步:按契约实现 gallery

<project>/playwright/gallery/ 实现页面,把请求的 story 渲染进 #root。建议从 spec 的 worked example 起步,结合 react.md / vue.md 中的框架笔记调整。要点:

  • story 发现(import.meta.glob)与框架挂载逻辑留在这里——这是唯一与框架耦合的胶水,保持它足够小;
  • 应用自身的全局 CSS 要用与 app 入口一致的方式导入。

第三步:配置 Playwright

playwright.config.ts 中加入(注意:配置里可能已有 projects/webServer,应合并而非替换):

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',                                       // 或: npx vite --config playwright/vite.config.ts
  url: 'http://localhost:5173/playwright/gallery/index.html',   // 独立 server 时为: http://localhost:3100/playwright/gallery/index.html
  reuseExistingServer: !process.env.CI,
},

三个关键选项的含义(结合 index.ts 源码理解):

  • 端口必须与 dev server 一致;因为 mount 会导航到 baseURL,所以 baseURL 必须就是 gallery 的 URL;
  • serviceWorkers: 'block':阻止应用自带 service worker 用缓存响应"盖住"你的 page.route() mock;
  • reuseContext: true:在一个 worker 内的多个测试间复用浏览器 context(旧组件测试运行器即如此),对组件测试套件是显著的提速。源码中它由 _reuseContext 夹具承接(见 index.ts 459-467 行),复用逻辑会跳过为每个测试新建 context 的开销。

第四至六步:写 story、写 spec、运行

  1. 参照 templates/react/Button.story.tsx(Vue 见 templates/vue/Button.story.ts),在现有组件旁写出第一个 story;
  2. 参照 templates/react/button.spec.ts,从 @playwright/test 引入 test/expect 写出第一个 spec;
  3. 运行 npx playwright test --project=components。浏览器打开 http://localhost:5173/playwright/gallery/index.html 即可肉眼浏览全部 story。

模板 spec 已经示范了最基础的三种断言形态——渲染正确文本、disabled 状态、点击回调副作用:

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

Gallery 契约详解

gallery-spec.md实现 gallery 时必须先读的入口文档。核心契约:

  • window.mount(params)params{ story, props },来自测试侧 mount(story, props)。把解析到的组件以 props 渲染进 #root,返回一个 Promise——组件挂载完成后 resolve,失败(未知 story、渲染抛错)时 reject。reject 会表现为测试里 await mount(...) 抛错并带真实堆栈,不存在 HTTP 状态码或 DOM 属性之类的信令。
  • 跨调用复用根节点component.update(props) 会以相同 story、新 props 不导航地再次调用 window.mount。若你渲染进同一个 root/实例而非每次重建,框架会进行 reconcile,组件内部状态得以保留——这就是 CT 的 update() 语义。每次重建 root(或导航)都会重置状态,所以应当:首次挂载时创建 root,之后的每次调用都渲染进它;只有当 story(组件类型)改变时框架才自行重挂载。
  • window.mount 就是你的 setup/teardown 钩子:它是浏览器侧对 CT 的 beforeMount/afterMount 的等价物——安装 provider 或插件、预置 store、在渲染前启动浏览器内 mock server、渲染后做收尾,全部放进这一个函数,按测试传入的 story/props 分支执行。没有单独的钩子注册表,"你拥有的那个函数就是钩子"。
  • window.unmount():把当前 story 从 #root 卸载并返回 Promise,由 component.unmount() 调用。只在需要断言卸载/清理副作用时用——因为每次 mount 都重新导航,测试天然隔离。
  • #root:渲染目标元素 id="root"mount 返回 #root 本身的 Locator,story 可以自由渲染 Fragment(例如组件外加一个记录状态的隐藏表单)。

Story id 语法(推荐)

gallery 拥有解析权,mount 原样透传 id。推荐文法:

  • <src 下路径(去掉 .story.* 后缀)>/<ExportName>——例如 src/components/Button.story.tsx 的具名导出 Primarycomponents/Button/Primary
  • 任何唯一的尾部后缀也能解析:Button/Primary
  • 单文件组件 story(Button.story.vue)本身就是一个 story,用路径直接寻址(其 default 导出):components/Button

React worked example(状态保留版)

spec 中的实例用 import.meta.glob 静态发现 story 文件,flushSync 包裹渲染以便渲染错误能正确 reject 而非被吞掉:

// 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);   // 复用 root 使 update() 走 reconcile 并保留状态
  // 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>

注意 import.meta.glob 必须内联在此文件:Vite 对它做静态分析、相对于该文件解析,因此不能挪进共享/发布代码——这正是"gallery 归你所有"的原因。该示例是契约的演示而非可复制文件,务必为你的技术栈实现等价物。

Vue 变体(状态保留)

Vue 的 createApp(...).mount() 每次调用都会构建全新实例,所以要先挂载一个小的响应式 host 并更新其 refs——更新 refs 会原地重渲染,这正是跨 update() 保留状态的机制:

// playwright/gallery/main.ts
import { createApp, h, shallowRef, type App, type Component } from 'vue';

// resolve() 与 import.meta.glob 与 React 示例相同。
const story = shallowRef<Component | null>(null);
const props = shallowRef<Record<string, any>>({});
const host = { render: () => (story.value ? h(story.value, props.value) : null) };
let app: App | undefined;

(window as any).mount = async ({ story: id, props: next }: { story: string, props?: Record<string, any> }) => {
  const resolved = await resolve(id);
  if (!resolved)
    throw new Error(`Unknown story: ${id}`);
  story.value = resolved;
  props.value = next ?? {};
  if (!app) {                    // 只挂载一次;上面的 ref 更新会原地重渲染
    app = createApp(host);
    app.mount('#root');
  }
};

(window as any).unmount = async () => {
  app?.unmount();
  app = undefined;
};

约定(Conventions)

  • Story id 即路径语义src/ 下的路径去掉 .story.* 后缀,再加上导出名——src/components/Button.story.tsx 导出 Primarycomponents/Button/Primary;任何唯一后缀也可用:mount('Button/Primary').story.vue 单文件组件即一个 story,仅凭其路径寻址(其 default 导出)。使用 gallery 类型(见 typing.md)后,id 会带包名前缀:acme-ui/components/Button/Primary
  • 一个场景一个导出:倾向新增 story 导出,而非给现有 story 参数化——story 是对组件各状态的"可 grep、可评审的文档"。

测试模式(Testing Patterns)

示例均以 React 给出;Vue 仅在 story 语法上有差异。

回调与事件:story 拥有状态并记录之

当组件接收回调时,在 story 内创建状态、把回调接上去、并把状态记录到组件旁的隐藏表单里。测试执行操作后对记录值断言:

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>
  </>;
};
test('click should expand', async ({ mount }) => {
  const component = await mount('components/Expandable/Stateful');
  await component.locator('.codicon-chevron-right').click();
  await expect(component.getByTestId('expanded')).toHaveValue('true');
});

这种写法的收益:整个场景留在浏览器里——没有回调编组(callback marshalling),story 本身充当文档,肉眼浏览 gallery 时记录的状态也可见。实践要点:

  • 每个被观测值记录进各自独立的 data-testid input;标量用 String(...),负载用 JSON.stringify(...)
  • toHaveValue() 断言——它是 web-first 断言,会自动重试直至状态落定
  • 负向场景同理:执行操作后断言值没有变化。

参考模板 Button.story.tsx 中的 CountsClicks:状态用 useState 由事件处理器更新,因此不受 React StrictMode 的双调用影响。

逐测试 props(Per-test props)

当场景确实参数化(例如边界值扫描)时,把 props 作为 mount 的第二参数传入;gallery 将其作为 props 交给 story。props 保持为纯可序列化数据——回调属于 story 内部

export const WithTitle = ({ title = 'Default' }: { title?: string }) =>
  <Button title={title} />;
const component = await mount('components/Button/WithTitle', { title: 'Hello' });

props 类型检查有两条可选路径,详见 typing.md:以 story 类型作模板参数(mount<typeof WithTitle>('components/Button/WithTitle', { title: 'Hello' }),零配置),或用小 Vite 插件生成 gallery 类型让 id 本身被类型化mount('acme-ui/components/Button/WithTitle', { title: 'Hello' }),带自动补全与重命名安全)。Vue story 还必须在运行时声明 props——见 react.md / vue.mdTyped props 小节。

update() 驱动 props 过渡

要测试组件对 prop 变化(且不重挂载、状态保留)的响应,调用 component.update(newProps)——它在现有 root 上以新 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');

这要求 gallery 复用其 root/实例(见 gallery-spec.md);只要 story 不变,状态即可存活。实现上,fixture 里的 update 只是不导航地再次 page.evaluate 调用 window.mount(见 index.ts 522 行)。

单测试多状态

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

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

做视觉对比时,对返回的 root Locator 截图(如上),而不是对整个页面截图,避免把浏览器 UI 边框也断言进去。

网络 Mock

按常规用 page.route()——务必在 mount() 之前注册,因为挂载会触发导航。配置里的 serviceWorkers: 'block' 能防止应用自己的 service worker 用缓存响应遮蔽这些路由。团队如果已有 MSW handler 库,也可以改为在 story 或 decorator 内部启动 MSW 的 setupWorker

调试 story

在浏览器打开 gallery URL(即 baseURL),在 devtools console 里执行 await window.mount({ story: 'components/Button/Primary' })——这正是 mount fixture 做的事。未知 story 会让 window.mount reject,表现为测试的 mount() 抛错并带真实堆栈。若想不用 console 也能浏览,可给 gallery 加一个可选的索引页。

React 专项要点

react.md 在通用工作流之上补充了 React 细节:

  • 文件布局playwright/gallery/(一个 index.html + main.tsx),需要 reactreact-dom 18+(用到 createRoot);story 放 src/**/*.story.tsx(glob 也收 .story.jsx)。
  • StrictMode:在 gallery 中把渲染的 story 包进 <React.StrictMode>,以对齐多数应用的渲染方式。开发构建里 StrictMode 会刻意双调用 render 与 effect——对用事件处理器里的状态更新来记录(如 CountsClicks)的 story 无影响;若某个 story 在 StrictMode 下行为异常,那往往是组件的真实问题,只有当应用本身不用 StrictMode 时才应去掉该包裹。
  • 全局 provider(theme/store/i18n/router):建一个共享 decorator 并在 story 里使用,让每个 story 只声明自己的场景:
// src/stories/decorators.tsx
export function AppScaffold({ children, route = '/' }: { children: React.ReactNode, route?: string }) {
  return (
    <ThemeProvider theme="light">
      <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
    </ThemeProvider>
  );
}
export const LoggedIn = () => (
  <AppScaffold route="/profile/42">
    <ProfilePage user={{ id: 42, name: 'Test User' }} />
  </AppScaffold>
);

不要把它做进 gallery——放在 story 文件里能让包裹关系可见,也允许 story 选择不用。

  • CSS:全局样式表在 gallery 入口导入(main.tsximport '../../src/index.css'),镜像 app 入口的做法;若 Tailwind 按路径扫描内容,确保 *.story.tsx 被覆盖。
  • 数据获取:对带客户端对象的库(React Query、Apollo),在 story 或 decorator 内创建客户端,让每次导航都从全新状态开始。

Vue 专项要点

vue.md 说明:Vue 下 gallery 以 app = createApp(h(story, props)); app.mount('#root') 挂载、app.unmount() 卸载,需要 Vue 3;story 放在 src/**/*.story.{ts,js,vue}

两种 story 写法

  1. 渲染函数 storyButton.story.ts)——一个文件多个场景,一个具名导出一个 story,用 defineComponent + h(),不涉及 SFC 编译;参考 templates/vue/Button.story.ts
  2. 单文件组件 storyButton.primary.story.vue)——一文件一 story,可用完整模板语法(含插槽),通过去掉扩展名的路径寻址:mount('components/Button.primary')当场景需要插槽或较复杂的模板时优先 SFC story
<script setup lang="ts">
import { ref } from 'vue';
import Button from './Button.vue';
const clicks = ref(0);
</script>

<template>
  <Button title="Submit" @click="clicks++" />
  <form hidden><input data-testid="click-count" readonly :value="String(clicks)" /></form>
</template>

全局插件:依赖 Pinia / vue-router / i18n 等插件的应用,用 decorator 包装每个 story,为每个 story 创建全新实例(如 withStore(story)createPinia());必须挂在 app 实例上的插件(app.use(...))则在 gallery 里 createApp(...) 之后立刻添加——相当于应用自己的引导过程。

Typed props:接收逐测试 props 的 story 要声明两次——setup 签名(提供类型)与 props 选项(让 Vue 当作 props 而非 attrs 投递):

export const WithTitle = defineComponent(
  (props: { title?: string }) => () => h(Button, { title: props.title ?? 'Default' }),
  { props: ['title'] },
);

Options-API story(defineComponent({ props: {...} }))推断方式相同。.story.vue SFC story 只有在 setup 生成 SFC 类型(Volar/vue-tsc)时才能推断 prop 类型;否则直接传 props 类型:mount<{ title?: string }>('components/Button.primary', { title: 'Hello' })

mount() 加类型:两套可选方案

typing.mdmount(storyId, props?) 的两条类型增强路径做了对比:

显式 story 类型 Gallery 类型
调用 mount<typeof WithTitle>('components/Button/WithTitle', { title }) mount('acme-ui/components/Button/WithTitle', { title })
检查范围 props 与 update() props、update() 以及 id 本身(自动补全、重命名安全)
需要 spec 里 import type { WithTitle } from './Button.story' 由 gallery 插件维护的生成文件 stories.d.ts
id 文法 gallery 能解析的任何后缀 完整 id,前缀包名

显式 story 类型零配置、任何项目可用;用 import type 确保 story(连同其中的 React/Vue 与 CSS import)永不加载进 Node 测试进程。函数组件、类组件与 defineComponent story 都能推断其 props。

Gallery 类型@playwright/test 导出一个空的 interface Stories {}——当 story id 是 Stories 的键时,mount 按该条目给 props 定类型;任何其他字符串维持未类型化 props,因此可部分采纳。gallery 旁的小 Vite 插件生成 stories.d.ts,通过模块增广填充 Stories

// playwright/gallery/stories.d.ts (生成)
type StoriesOf<Prefix extends string, Mod> = { [K in keyof Mod & string as `${Prefix}/${K}`]: Mod[K] };

declare module '@playwright/test' {
  interface Stories extends
    StoriesOf<'acme-ui/components/Button', typeof import('../../src/components/Button.story')>,
    StoriesOf<'acme-ui/components/Expandable', typeof import('../../src/components/Expandable.story')> {}
}

export {};

每行对应一个 story 文件;导出名与 props 类型来自 story 模块本身,所以该文件只在 story 文件新增/删除/移动时才变化。spec 侧于是无需任何类型 import:

const component = await mount('acme-ui/components/Button/WithTitle', { title: 'Hello' });
//                            ^ 自动补全已注册 id       ^ 对 WithTitle 的 props 做类型检查

用包名给 id 加命名空间

Stories 按 TypeScript program 合并。在共享同一 tsconfig 的 monorepo 里,两个包都注册 components/Button/Default 且 props 不同,会在 Stories 声明处报 TS2320: Interface 'Stories' cannot simultaneously extend types ...,即使 id 不同也会互相污染自动补全。因此从一开始就给每个 id 加包名前缀<package>/<src 下路径>/<Export>),并在两处派生 id 的地方都从 package.json 读取包名,避免漂移:

// playwright/gallery/main.tsx
import packageJSON from '../../package.json';

const id = (f: string) => packageJSON.name + '/' + f.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');

后缀解析在运行时仍可用(mount('Button/Primary') 照常渲染),只是只有完整带前缀的 id 才被类型化

生成插件

插件与框架无关——它只负责列出 story 文件。核心逻辑是 buildStart 时生成、dev server 运行期间监听文件新增/删除时重新生成,且内容确定

// playwright/gallery/storyTypes.ts
const storyFile = /\.story\.(tsx|jsx)$/;

export function storyTypes(options: { prefix: string, src: string, outFile: string }): Plugin {
  const generate = () => {
    const content = render(listStoryFiles(options.src), options);
    if (fs.existsSync(options.outFile) && fs.readFileSync(options.outFile, 'utf8') === content)
      return;
    fs.writeFileSync(options.outFile, content);
  };
  return {
    name: 'story-types',
    buildStart() {
      this.addWatchFile(options.src);
      generate();
    },
    configureServer(server) {
      const onFile = (file: string) => {
        if (storyFile.test(file))
          generate();
      };
      server.watcher.on('add', onFile);
      server.watcher.on('unlink', onFile);
    },
  };
}

listStoryFiles 递归扫描 srcrender 把每个文件渲染成一行 StoriesOf<'前缀/相对路径', typeof import('...')>。注册在任意一个托管 gallery 的 Vite server 上(应用的 vite.config.ts 或独立 playwright/vite.config.ts):

export default defineConfig({
  plugins: [
    react(),
    storyTypes({
      prefix: packageJSON.name,
      src: path.resolve(__dirname, 'src'),
      outFile: path.resolve(__dirname, 'playwright/gallery/stories.d.ts'),
    }),
  ],
});

该文件在每次 dev server 启动与构建时重新生成,运行期间 story 文件增删也会触发;内容确定,所以请提交它——CI 或编辑器中的类型检查不能依赖运行中的 dev server。没有任何代码 import 它,需在项目 tsconfig 中显式列出("files": ["playwright/gallery/stories.d.ts"])以便 tsc 与编辑器加载。对 .story.vue 单文件组件,在 StoriesOf 中把 default 导出映射到裸路径(K extends 'default' ? Prefix : \Prefix/{Prefix}/{K}`),并依赖 vue-tsc提供 SFC 的类型;无法推断 props 的 story 会以unknown` props 呈现——它接受任何值。

决策点(Decision Points)

  • Monorepo / 非 src 布局:改掉 gallery 里的 glob 与 id 派生逻辑(gallery-spec.md),并按 typing.md 给 id 加包名前缀。
  • 全局 provider(theme、i18n、store、router):在 gallery 旁建共享 decorator 帮助函数,在 story 里包裹组件;参见 react.md / vue.md

@playwright/experimental-ct-react / -vue 迁移

migration.md 是存量 CT 用户的完整迁移手册。CT 包会编译测试文件里的 JSX 并编组进浏览器;gallery 模式把场景移入 story 导出、让其原生运行在浏览器:结构(哪个组件、子节点、provider)加行为(状态与回调、记录进隐藏表单供测试断言)。纯数据 props 经由 mount(storyId, props) 传递;update() / unmount() 语义不变。

概念映射表(CT → Gallery):

@playwright/experimental-ct-* Gallery 模式
mount(<Button title="…" onClick={spy} />) Stateful story:story 提供 onClick,把效果记录进隐藏表单输入;测试断言 toHaveValue()
测试传入的纯数据 props 精神不变:mount(id, props)
测试里的 JSX 子节点/插槽 无法跨进程——把每种组合烘进各自的 story 导出(Vue 用 .story.vue 承载插槽密集场景)
component.update(<Button count={2} />) component.update({ count: 2 })——保留状态,需 gallery 复用 root
component.unmount() component.unmount()——由 gallery 的 window.unmount() 支撑
playwright/index.ts 中的 beforeMount/afterMount gallery window.mount 的函数体(全局),或 story decorator(按 story)
按测试变化的 hooksConfig Props:mount('App/Routing', { route: '/dashboard' })——由 story/decorator 解释
Node 侧的 router fixture / MSW handlers 测试里 page.route(),或 story/decorator 内 MSW setupWorker
playwright/index.html(样式、字体、主题) gallery 的 index.html / 入口模块 imports
ctViteConfigctPortctTemplateDirctCacheDir 已消失——gallery 走应用自己的 dev server;端口在 webServer + baseURL;位置固定 playwright/gallery/
来自 CT 包的 defineConfig 普通 @playwright/testdefineConfig,配 baseURL = gallery URL、serviceWorkers: 'block'reuseContext: true

迁移步骤

  1. 按 SKILL.md 搭好 gallery 与配置,旧 CT 项目保持运行直到最后一个 spec 迁移完毕;
  2. 逐个 CT spec 拆分每个 mount(<…/>):JSX 结构变成组件旁的 story 导出;纯数据 props 留在测试里作为 mount 第二参数;回调 spy 变成记录进隐藏表单的 story 状态。只变化数据 props 的调用点通常只需要一个展开它们的通用 story:export const Default = (props: ButtonProps) => <Button title="Submit" {...props} />
  3. 重写 spec:从 @playwright/test 引入 test/expectmount(<X a={1}/>)mount('X/Default', { a: 1 })update(<X a={2}/>)update({ a: 2 })unmount() 不变。mount 返回 gallery 根节点 Locator——查询要收敛范围(component.getByRole('button').click());spy 断言改为对 story 记录状态的 toHaveValue()
  4. 迁移 beforeMount 钩子:应用级 setup 进 gallery 的 window.mount;按测试的 hooksConfig 分支改成由 story 或 decorator 解释的 props;
  5. 全部 spec 变绿后,从配置删除 CT 项目、去掉 @playwright/experimental-ct-* 依赖、删除 playwright/index.htmlplaywright/index.ts*playwright/.cache,并解除 Playwright 版本锁(迁移期间需固定于 1.62,因为 CT 包在 1.63 移除后不再发布)。

坑点提醒

  • Story id 是字符串:重命名或移动 story 会在运行时而非编译期破坏 spec——且后缀匹配解析在重命名后可能静默命中另一个 story。Gallery 类型能把这些已注册 id 变成编译期检查。
  • 逐测试 JSX 不复存在:任何按测试构建不同 JSX 树的做法(子节点矩阵、内联包装)都要改成"每种组合一个 story 导出"。

迁移前后对照

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

test('click', async ({ mount }) => {
  const messages: string[] = [];
  const component = await mount(<Button title="Submit" onClick={data => messages.push(data)} />);
  await component.click();
  expect(messages).toEqual(['hello']);
});
// After: src/components/Button.story.tsx
import Button from './Button';

export const Default = (props: { onClick?: (data: string) => void }) =>
  <Button title="Submit" {...props} />;
// After: src/components/Button.spec.ts
import { test, expect } from '@playwright/test';

test('click', async ({ mount }) => {
  const messages: string[] = [];
  const component = await mount('components/Button/Default', { onClick: (data: string) => messages.push(data) });
  await component.click();
  expect(messages).toEqual(['hello']);
});

需要说明的是:SKILL.md 与 gallery-spec 推荐"纯数据 props、回调留在 story"的强约定,但 fixture 实现向 page.evaluate 传入了 { exposeFunctions: true }(见 index.ts 505-511 行),因此函数值 props 具备被编组为浏览器可调用函数的底层能力,迁移后仍保留这种写法;把状态记录在浏览器侧仍是推荐路径,因为它同时获得可观察性与"story 即文档"的价值。

参考资料索引(SKILL.md References 全量展开)

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