组件测试(实验性功能)
简介
Playwright Test 现已支持测试您的组件。
示例
以下是一个典型的组件测试示例:
test('事件应该正常工作', async ({ mount }) => {
let clicked = false;
// 挂载一个组件。返回指向该组件的定位器。
const component = await mount(
<Button title="提交" onClick={() => { clicked = true }}></Button>
);
// 与任何 Playwright 测试一样,断言定位器文本。
await expect(component).toContainText('提交');
// 执行定位器点击操作。这将触发事件。
await component.click();
// 断言相应事件已被触发。
expect(clicked).toBeTruthy();
});
如何开始
将 Playwright Test 添加到现有项目非常简单。以下是启用 React、Vue 或 Svelte 项目的 Playwright Test 的步骤。
第一步:为您的框架安装 Playwright 组件测试
- npm
- yarn
- pnpm
npm init playwright@latest -- --ct
yarn create playwright --ct
pnpm create playwright --ct
此步骤将在您的工作区创建以下文件:
<html lang="en">
<body>
<div id="root"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>
该文件定义了一个用于测试期间渲染组件的 HTML 文件。它必须包含 id="root"
的元素,这是组件挂载的位置。同时必须链接名为 playwright/index.{js,ts,jsx,tsx}
的脚本。
您可以通过此脚本包含样式表、应用主题以及向组件挂载页面注入代码。该文件可以是 .js
、.ts
、.jsx
或 .tsx
格式。
// 在此处应用主题,添加组件运行时所需的任何内容
步骤 2. 创建测试文件 src/App.spec.{ts,tsx}
- React
- Svelte
- Vue
import { test, expect } from '@playwright/experimental-ct-react';
import App from './App';
test('should work', async ({ mount }) => {
const component = await mount(<App />);
await expect(component).toContainText('Learn React');
});
import { test, expect } from '@playwright/experimental-ct-vue';
import App from './App.vue';
test('should work', async ({ mount }) => {
const component = await mount(App);
await expect(component).toContainText('Learn Vue');
});
import { test, expect } from '@playwright/experimental-ct-vue';
import App from './App.vue';
test('should work', async ({ mount }) => {
const component = await mount(<App />);
await expect(component).toContainText('Learn Vue');
});
如果使用 TypeScript 和 Vue,请确保在项目中添加 vue.d.ts
文件:
declare module '*.vue';
import { test, expect } from '@playwright/experimental-ct-svelte';
import App from './App.svelte';
test('should work', async ({ mount }) => {
const component = await mount(App);
await expect(component).toContainText('Learn Svelte');
});
步骤 3. 运行测试
您可以使用 VS Code 扩展 或命令行运行测试。
npm run test-ct
扩展阅读:配置报告、浏览器、追踪
参考 Playwright 配置 来配置您的项目。
测试故事(Test stories)
当使用 Playwright Test 测试 Web 组件时,测试运行在 Node.js 环境中,而组件运行在真实的浏览器中。这结合了两者的优势:组件运行在真实的浏览器环境中,触发真实的点击事件,执行真实的布局,可以进行视觉回归测试。同时,测试可以利用 Node.js 的全部能力以及 Playwright Test 的所有功能。因此,在组件测试中同样可以使用并行化、参数化测试以及相同的故障追踪功能。
然而,这也带来了一些限制:
- 不能向组件传递复杂的活动对象。只能传递普通的 JavaScript 对象和内置类型,如字符串、数字、日期等。
test('这个测试会通过', async ({ mount }) => {
const component = await mount(<ProcessViewer process={{ name: 'playwright' }}/>);
});
test('这个测试不会通过', async ({ mount }) => {
// `process` 是 Node 对象,我们无法将其传递到浏览器并期望它能正常工作
const component = await mount(<ProcessViewer process={process}/>);
});
- 不能通过回调函数同步地向组件传递数据:
test('这个测试不会通过', async ({ mount }) => {
// () => 'red' 回调函数存在于 Node 环境中。如果浏览器中的 `ColorPicker` 组件调用参数函数
// `colorGetter`,它将无法同步获取结果。虽然可以通过 await 获取,但这不符合
// 组件的常规构建方式
const component = await mount(<ColorPicker colorGetter={() => 'red'}/>);
});
解决这些限制的方法既快速又优雅:为被测组件的每个使用场景创建一个专门用于测试的包装组件。这不仅能规避限制,还能为测试提供强大的抽象能力,让你可以定义组件的渲染环境、主题等各个方面。
假设你需要测试以下组件:
import React from 'react';
type InputMediaProps = {
// Media 是一个复杂的浏览器对象,我们无法在测试时将其发送到 Node 环境
onChange(media: Media): void;
};
export function InputMedia(props: InputMediaProps) {
return <></> as any;
}
为你的组件创建一个故事文件:
import React from 'react';
import InputMedia from './import-media';
type InputMediaForTestProps = {
onMediaChange(mediaName: string): void;
};
export function InputMediaForTest(props: InputMediaForTestProps) {
// 不是传递复杂的 `media` 对象给测试,而是传递媒体名称
return <InputMedia onChange={media => props.onMediaChange(media.name)} />;
}
// 在这里导出更多故事
然后通过测试故事来测试组件:
import { test, expect } from '@playwright/experimental-ct-react';
import { InputMediaForTest } from './input-media.story.tsx';
test('更改图片', async ({ mount }) => {
let mediaSelected: string | null = null;
const component = await mount(
<InputMediaForTest
onMediaChange={mediaName => {
mediaSelected = mediaName;
}}
/>
);
await component
.getByTestId('imageInput')
.setInputFiles('src/assets/logo.png');
await expect(component.getByAltText(/selected image/i)).toBeVisible();
await expect.poll(() => mediaSelected).toBe('logo.png');
});
最终,每个组件都会有一个故事文件,导出所有实际被测试的故事。这些故事存在于浏览器中,并将复杂对象转换为测试中可以访问的简单对象。
底层原理
组件测试的工作原理如下:
- 测试执行时,Playwright 会创建测试所需的组件列表
- 然后编译包含这些组件的 bundle,并使用本地静态 web 服务器提供服务
- 当测试中调用
mount
时,Playwright 会导航到这个 bundle 的门面页面/playwright/index.html
,并通知它渲染组件 - 事件会被序列化回 Node.js 环境以进行验证
Playwright 使用 Vite 来创建组件 bundle 并提供服务。
API 参考
props
在挂载组件时提供 props。
- React
- Svelte
- Vue
import { test } from '@playwright/experimental-ct-react';
test('props', async ({ mount }) => {
const component = await mount(<Component msg="greetings" />);
});
import { test } from '@playwright/experimental-ct-svelte';
test('props', async ({ mount }) => {
const component = await mount(Component, { props: { msg: 'greetings' } });
});
import { test } from '@playwright/experimental-ct-vue';
test('props', async ({ mount }) => {
const component = await mount(Component, { props: { msg: 'greetings' } });
});
// 或者也可以使用 `jsx` 风格
import { test } from '@playwright/experimental-ct-vue';
test('props', async ({ mount }) => {
const component = await mount(<Component msg="greetings" />);
});
回调/事件
在组件挂载时提供回调/事件处理。
- React
- Svelte
- Vue
import { test } from '@playwright/experimental-ct-react';
test('callback', async ({ mount }) => {
const component = await mount(<Component onClick={() => {}} />);
});
import { test } from '@playwright/experimental-ct-svelte';
test('event', async ({ mount }) => {
const component = await mount(Component, { on: { click() {} } });
});
import { test } from '@playwright/experimental-ct-vue';
test('event', async ({ mount }) => {
const component = await mount(Component, { on: { click() {} } });
});
// 或者也可以使用 `jsx` 风格
import { test } from '@playwright/experimental-ct-vue';
test('event', async ({ mount }) => {
const component = await mount(<Component v-on:click={() => {}} />);
});
子元素 / 插槽
在挂载组件时提供子元素/插槽内容。
- React
- Svelte
- Vue
import { test } from '@playwright/experimental-ct-react';
test('children', async ({ mount }) => {
const component = await mount(<Component>Child</Component>);
});
import { test } from '@playwright/experimental-ct-svelte';
test('slot', async ({ mount }) => {
const component = await mount(Component, { slots: { default: 'Slot' } });
});
import { test } from '@playwright/experimental-ct-vue';
test('slot', async ({ mount }) => {
const component = await mount(Component, { slots: { default: 'Slot' } });
});
// 或者也可以使用 `jsx` 风格
import { test } from '@playwright/experimental-ct-vue';
test('children', async ({ mount }) => {
const component = await mount(<Component>Child</Component>);
});
钩子函数
您可以使用 beforeMount
和 afterMount
钩子来配置您的应用。这允许您设置诸如应用路由、模拟服务器等内容,为您提供所需的灵活性。您还可以从测试中的 mount
调用传递自定义配置,这些配置可以通过 hooksConfig
fixture 访问。这包括任何需要在组件挂载前后运行的配置。下面是一个配置路由器的示例:
- React
- Vue
import { beforeMount, afterMount } from '@playwright/experimental-ct-react/hooks';
import { BrowserRouter } from 'react-router-dom';
export type HooksConfig = {
enableRouting?: boolean;
}
beforeMount<HooksConfig>(async ({ App, hooksConfig }) => {
if (hooksConfig?.enableRouting)
return <BrowserRouter><App /></BrowserRouter>;
});
import { test, expect } from '@playwright/experimental-ct-react';
import type { HooksConfig } from '../playwright';
import { ProductsPage } from './pages/ProductsPage';
test('通过钩子配置路由', async ({ page, mount }) => {
const component = await mount<HooksConfig>(<ProductsPage />, {
hooksConfig: { enableRouting: true },
});
await expect(component.getByRole('link')).toHaveAttribute('href', '/products/42');
});
import { beforeMount, afterMount } from '@playwright/experimental-ct-vue/hooks';
import { router } from '../src/router';
export type HooksConfig = {
enableRouting?: boolean;
}
beforeMount<HooksConfig>(async ({ app, hooksConfig }) => {
if (hooksConfig?.enableRouting)
app.use(router);
});
import { test, expect } from '@playwright/experimental-ct-vue';
import type { HooksConfig } from '../playwright';
import ProductsPage from './pages/ProductsPage.vue';
test('通过钩子配置路由', async ({ page, mount }) => {
const component = await mount<HooksConfig>(ProductsPage, {
hooksConfig: { enableRouting: true },
});
await expect(component.getByRole('link')).toHaveAttribute('href', '/products/42');
});
unmount
将已挂载的组件从 DOM 中卸载。此方法对于测试组件在卸载时的行为非常有用。典型使用场景包括测试"确定要离开吗?"模态框,或确保正确清理事件处理器以防止内存泄漏。
- React
- Svelte
- Vue
import { test } from '@playwright/experimental-ct-react';
test('unmount', async ({ mount }) => {
const component = await mount(<Component/>);
await component.unmount();
});
import { test } from '@playwright/experimental-ct-svelte';
test('unmount', async ({ mount }) => {
const component = await mount(Component);
await component.unmount();
});
import { test } from '@playwright/experimental-ct-vue';
test('unmount', async ({ mount }) => {
const component = await mount(Component);
await component.unmount();
});
// 或者也可以使用 `jsx` 风格
import { test } from '@playwright/experimental-ct-vue';
test('unmount', async ({ mount }) => {
const component = await mount(<Component/>);
await component.unmount();
});
更新组件
更新已挂载组件的 props、插槽/子组件和/或事件/回调。这些组件输入可以在任何时候发生变化,通常由父组件提供,但有时需要确保你的组件能够正确处理新的输入。
- React
- Svelte
- Vue
import { test } from '@playwright/experimental-ct-react';
test('update', async ({ mount }) => {
const component = await mount(<Component/>);
await component.update(
<Component msg="greetings" onClick={() => {}}>Child</Component>
);
});
import { test } from '@playwright/experimental-ct-svelte';
test('update', async ({ mount }) => {
const component = await mount(Component);
await component.update({
props: { msg: 'greetings' },
on: { click() {} },
slots: { default: 'Child' }
});
});
import { test } from '@playwright/experimental-ct-vue';
test('update', async ({ mount }) => {
const component = await mount(Component);
await component.update({
props: { msg: 'greetings' },
on: { click() {} },
slots: { default: 'Child' }
});
});
// 或者也可以使用 `jsx` 风格
import { test } from '@playwright/experimental-ct-vue';
test('update', async ({ mount }) => {
const component = await mount(<Component/>);
await component.update(
<Component msg="greetings" v-on:click={() => {}}>Child</Component>
);
});
处理网络请求
Playwright 提供了一个实验性的 router
fixture 来拦截和处理网络请求。使用 router
fixture 有两种方式:
- 调用
router.route(url, handler)
,其行为类似于 page.route()。更多细节请参阅 网络模拟指南。 - 调用
router.use(handlers)
并传入 MSW 库 的请求处理器。
以下是在测试中复用现有 MSW 处理器的示例:
import { handlers } from '@src/mocks/handlers';
test.beforeEach(async ({ router }) => {
// 在每个测试前安装通用处理器
await router.use(...handlers);
});
test('示例测试', async ({ mount }) => {
// 正常进行测试,你的处理器已激活
// ...
});
你也可以为特定测试引入一次性处理器:
import { http, HttpResponse } from 'msw';
test('示例测试', async ({ mount, router }) => {
await router.use(http.get('/data', async ({ request }) => {
return HttpResponse.json({ value: 'mocked' });
}));
// 正常进行测试,你的处理器已激活
// ...
});
常见问题
@playwright/test
和 @playwright/experimental-ct-{react,svelte,vue}
有什么区别?
test('…', async ({ mount, page, context }) => {
// …
});
@playwright/experimental-ct-{react,svelte,vue}
封装了 @playwright/test
,额外提供了一个专门用于组件测试的内置 fixture 叫做 mount
:
- React
- Svelte
- Vue
import { test, expect } from '@playwright/experimental-ct-react';
import HelloWorld from './HelloWorld';
test.use({ viewport: { width: 500, height: 500 } });
test('should work', async ({ mount }) => {
const component = await mount(<HelloWorld msg="greetings" />);
await expect(component).toContainText('Greetings');
});
import { test, expect } from '@playwright/experimental-ct-vue';
import HelloWorld from './HelloWorld.vue';
test.use({ viewport: { width: 500, height: 500 } });
test('should work', async ({ mount }) => {
const component = await mount(HelloWorld, {
props: {
msg: 'Greetings',
},
});
await expect(component).toContainText('Greetings');
});
import { test, expect } from '@playwright/experimental-ct-svelte';
import HelloWorld from './HelloWorld.svelte';
test.use({ viewport: { width: 500, height: 500 } });
test('should work', async ({ mount }) => {
const component = await mount(HelloWorld, {
props: {
msg: 'Greetings',
},
});
await expect(component).toContainText('Greetings');
});
此外,它还在 playwright-ct.config.{ts,js}
配置文件中添加了一些可用的配置选项。
最后,在底层实现上,为了优化组件测试的速度,每个测试会复用 context
和 page
fixture。不过它会在每个测试之间重置这些 fixture,因此在功能上应该等同于 @playwright/test
的保证:每个测试都会获得一个全新的、隔离的 context
和 page
fixture。
我的项目已经使用了 Vite,能否复用现有配置?
目前 Playwright 是与打包工具无关的,因此不会直接复用你现有的 Vite 配置。你的配置可能包含许多我们无法复用的内容。所以目前的做法是,你需要将路径映射和其他高级设置复制到 Playwright 配置的 ctViteConfig
属性中。
import { defineConfig } from '@playwright/experimental-ct-react';
export default defineConfig({
use: {
ctViteConfig: {
// ...
},
},
});
你可以通过 Vite 配置来指定测试环境所需的插件。请注意,一旦开始指定插件,你还需要自行包含框架插件,本例中就是 vue()
:
import { defineConfig, devices } from '@playwright/experimental-ct-vue';
import { resolve } from 'path';
import vue from '@vitejs/plugin-vue';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
export default defineConfig({
testDir: './tests/component',
use: {
trace: 'on-first-retry',
ctViteConfig: {
plugins: [
vue(),
AutoImport({
imports: [
'vue',
'vue-router',
'@vueuse/head',
'pinia',
{
'@/store': ['useStore'],
},
],
dts: 'src/auto-imports.d.ts',
eslintrc: {
enabled: true,
},
}),
Components({
dirs: ['src/components'],
extensions: ['vue'],
}),
],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
},
},
});
如何使用 CSS 导入?
如果你的组件需要导入 CSS,Vite 会自动处理这些导入。你也可以使用 Sass、Less 或 Stylus 等 CSS 预处理器,Vite 同样会处理它们而无需额外配置。不过需要安装对应的 CSS 预处理器。
Vite 有一个硬性要求:所有 CSS Modules 必须命名为 *.module.[css 扩展名]
。如果你的项目有自定义构建配置,并且使用了类似 import styles from 'styles.css'
的导入方式,你必须重命名文件以明确表明它们应被视为模块。你也可以编写一个 Vite 插件来处理这种情况。
更多详情请查看 Vite 文档。
如何测试使用 Pinia 的组件?
Pinia 需要在 playwright/index.{js,ts,jsx,tsx}
文件中进行初始化。如果在 beforeMount
钩子中进行初始化,则可以在每个测试中覆盖 initialState
:
import { beforeMount, afterMount } from '@playwright/experimental-ct-vue/hooks';
import { createTestingPinia } from '@pinia/testing';
import type { StoreState } from 'pinia';
import type { useStore } from '../src/store';
export type HooksConfig = {
store?: StoreState<ReturnType<typeof useStore>>;
}
beforeMount<HooksConfig>(async ({ hooksConfig }) => {
createTestingPinia({
initialState: hooksConfig?.store,
/**
* 使用 HTTP 拦截来模拟 API 调用:
* https://playwright.dev/docs/mock#mock-api-requests
*/
stubActions: false,
createSpy(args) {
console.log('spy', args)
return () => console.log('spy-returns')
},
});
});
import { test, expect } from '@playwright/experimental-ct-vue';
import type { HooksConfig } from '../playwright';
import Store from './Store.vue';
test('覆盖 initialState', async ({ mount }) => {
const component = await mount<HooksConfig>(Store, {
hooksConfig: {
store: { name: 'override initialState' }
}
});
await expect(component).toContainText('override initialState');
});
如何访问组件的方法或其实例?
在测试代码中访问组件的内部方法或其实例既不推荐也不受支持。相反,应该专注于从用户角度观察和与组件交互,通常是通过点击或验证页面上是否可见某些内容。当测试避免与内部实现细节(如组件实例或其方法)交互时,测试会变得更加健壮和有价值。请记住,如果从用户角度运行时测试失败,很可能意味着自动化测试发现了代码中的真实缺陷。