组件(实验性)
简介
Playwright Test 现在可以测试您的组件了。
示例
典型的组件测试如下所示
test('event should work', async ({ mount }) => {
let clicked = false;
// Mount a component. Returns locator pointing to the component.
const component = await mount(
<Button title="Submit" onClick={() => { clicked = true }}></Button>
);
// As with any Playwright test, assert locator text.
await expect(component).toContainText('Submit');
// Perform locator click. This will trigger the event.
await component.click();
// Assert that respective events have been fired.
expect(clicked).toBeTruthy();
});
如何开始
向现有项目添加 Playwright Test 非常简单。以下是为 React 或 Vue 项目启用 Playwright Test 的步骤。
第 1 步:为您的框架安装 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 文件。
// Apply theme here, add anything your component needs at runtime here.
第 2 步:创建测试文件 src/App.spec.{ts,tsx}
- React
- 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';
第 3 步:运行测试
您可以使用 VS Code 扩展或命令行运行测试。
npm run test-ct
进一步阅读:配置报告、浏览器、追踪
请参阅 Playwright 配置以配置您的项目。
测试故事 (Test stories)
当使用 Playwright Test 测试 Web 组件时,测试在 Node.js 中运行,而组件在真实的浏览器中运行。这结合了二者的优点:组件在真实的浏览器环境中运行,触发真实的点击,执行真实的布局,并且可以进行视觉回归测试。同时,测试可以使用 Node.js 的所有强大功能以及所有 Playwright Test 特性。因此,在组件测试期间,可以使用相同的并行、参数化测试以及相同的事后追踪功能。
然而,这也带来了一些限制:
- 您不能将复杂的实时对象传递给组件。只能传递纯 JavaScript 对象和内置类型,如字符串、数字、日期等。
test('this will work', async ({ mount }) => {
const component = await mount(<ProcessViewer process={{ name: 'playwright' }}/>);
});
test('this will not work', async ({ mount }) => {
// `process` is a Node object, we can't pass it to the browser and expect it to work.
const component = await mount(<ProcessViewer process={process}/>);
});
- 您不能在回调中同步地将数据传递给组件。
test('this will not work', async ({ mount }) => {
// () => 'red' callback lives in Node. If `ColorPicker` component in the browser calls the parameter function
// `colorGetter` it won't get result synchronously. It'll be able to get it via await, but that is not how
// components are typically built.
const component = await mount(<ColorPicker colorGetter={() => 'red'}/>);
});
解决这些及其他限制的方法既快速又优雅:对于被测组件的每个用例,创建一个专为测试而设计的组件包装器(Wrapper)。这不仅可以缓解上述限制,还能为测试提供强大的抽象,使您能够定义环境、主题和组件渲染的其他方面。
假设您想测试以下组件:
import React from 'react';
type InputMediaProps = {
// Media is a complex browser object we can't send to Node while testing.
onChange(media: Media): void;
};
export function InputMedia(props: InputMediaProps) {
return <></> as any;
}
为您的组件创建一个故事(story)文件
import React from 'react';
import InputMedia from './import-media';
type InputMediaForTestProps = {
onMediaChange(mediaName: string): void;
};
export function InputMediaForTest(props: InputMediaForTestProps) {
// Instead of sending a complex `media` object to the test, send the media name.
return <InputMedia onChange={media => props.onMediaChange(media.name)} />;
}
// Export more stories here.
然后通过测试故事来测试组件
import { test, expect } from '@playwright/experimental-ct-react';
import { InputMediaForTest } from './input-media.story.tsx';
test('changes the image', 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 就会创建一个测试所需的组件列表。
- 然后它会将这些组件编译成一个包,并使用本地静态 Web 服务器进行服务。
- 在测试中调用
mount时,Playwright 会导航到该包的门面页面/playwright/index.html,并通知它渲染组件。 - 事件会被编组回 Node.js 环境以进行验证。
Playwright 使用 Vite 来创建组件包并进行服务。
最佳实践与常见陷阱
当组件测试充分利用“测试在 Node.js 中运行,而挂载的组件在浏览器中运行”这一事实时,它们最为可靠。
倾向于在每个测试中进行挂载
请将 mount() 放在使用它的断言附近。在 beforeEach 中进行挂载会使人难以区分组件状态属于哪个测试,并且往往会掩盖测试之间意外的耦合。
test('renders the product name', async ({ mount }) => {
const component = await mount(<ProductCard name="Playwright" />);
await expect(component).toContainText('Playwright');
});
模块模拟 (Module mocks) 不会跨越 Node/浏览器边界
诸如 vi.mock() 或 jest.mock() 之类的模块级模拟在测试进程中运行。组件包在浏览器中运行,因此这些模拟不会自动影响组件在运行时导入的内容。请优先通过 hooksConfig 传递特定于测试的行为,并使用 beforeMount 在 playwright/index.{js,ts,jsx,tsx} 中进行配置。
当组件依赖全局变量时,请重置浏览器状态
作为一种性能优化,组件测试可能会在测试之间重用浏览器的 context 和 page。如果组件依赖于全局浏览器状态(如 localStorage、cookies、单例服务或路由状态),请在测试设置或 beforeMount 中重置该状态,以便每个测试都从已知的基准开始。
API 参考
props
在挂载时为组件提供 props。
- React
- 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-vue';
test('props', async ({ mount }) => {
const component = await mount(Component, { props: { msg: 'greetings' } });
});
// Or alternatively, using the `jsx` style
import { test } from '@playwright/experimental-ct-vue';
test('props', async ({ mount }) => {
const component = await mount(<Component msg="greetings" />);
});
回调 / 事件
在挂载时为组件提供回调/事件。
- React
- Vue
import { test } from '@playwright/experimental-ct-react';
test('callback', async ({ mount }) => {
const component = await mount(<Component onClick={() => {}} />);
});
import { test } from '@playwright/experimental-ct-vue';
test('event', async ({ mount }) => {
const component = await mount(Component, { on: { click() {} } });
});
// Or alternatively, using the `jsx` style
import { test } from '@playwright/experimental-ct-vue';
test('event', async ({ mount }) => {
const component = await mount(<Component v-on:click={() => {}} />);
});
子元素 / 插槽
在挂载时为组件提供子元素/插槽。
- React
- 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-vue';
test('slot', async ({ mount }) => {
const component = await mount(Component, { slots: { default: 'Slot' } });
});
// Or alternatively, using the `jsx` style
import { test } from '@playwright/experimental-ct-vue';
test('children', async ({ mount }) => {
const component = await mount(<Component>Child</Component>);
});
钩子 (hooks)
您可以使用 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('configure routing through hooks config', 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('configure routing through hooks config', async ({ page, mount }) => {
const component = await mount<HooksConfig>(ProductsPage, {
hooksConfig: { enableRouting: true },
});
await expect(component.getByRole('link')).toHaveAttribute('href', '/products/42');
});
卸载 (unmount)
从 DOM 中卸载已挂载的组件。这对于测试组件在卸载时的行为非常有用。使用场景包括测试“您确定要离开吗?”模态框,或确保正确清理事件处理程序以防止内存泄漏。
- React
- 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-vue';
test('unmount', async ({ mount }) => {
const component = await mount(Component);
await component.unmount();
});
// Or alternatively, using the `jsx` style
import { test } from '@playwright/experimental-ct-vue';
test('unmount', async ({ mount }) => {
const component = await mount(<Component/>);
await component.unmount();
});
更新 (update)
更新已挂载组件的 props、插槽/子元素和/或事件/回调。这些组件输入可以随时更改,通常由父组件提供,但有时需要确保您的组件对新的输入表现得当。
- React
- 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-vue';
test('update', async ({ mount }) => {
const component = await mount(Component);
await component.update({
props: { msg: 'greetings' },
on: { click() {} },
slots: { default: 'Child' }
});
});
// Or alternatively, using the `jsx` style
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 夹具来拦截和处理网络请求。有两种使用 router 夹具的方法:
- 调用
router.route(url, handler),其行为类似于 page.route()。有关更多详细信息,请参阅 网络模拟指南。 - 调用
router.use(handlers)并向其传递 MSW 库请求处理程序。
以下是在测试中重用现有 MSW 处理程序的示例。
import { handlers } from '@src/mocks/handlers';
test.beforeEach(async ({ router }) => {
// install common handlers before each test
await router.use(...handlers);
});
test('example test', async ({ mount }) => {
// test as usual, your handlers are active
// ...
});
您还可以为特定的测试引入一次性处理程序。
import { http, HttpResponse } from 'msw';
test('example test', async ({ mount, router }) => {
await router.use(http.get('/data', async ({ request }) => {
return HttpResponse.json({ value: 'mocked' });
}));
// test as usual, your handler is active
// ...
});
常见问题解答
@playwright/test 和 @playwright/experimental-ct-{react,vue} 之间有什么区别?
test('…', async ({ mount, page, context }) => {
// …
});
@playwright/experimental-ct-{react,vue} 包装了 @playwright/test,提供了一个额外的内置组件测试特定夹具,称为 mount。
- React
- 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');
});
此外,它添加了一些您可以在 playwright-ct.config.{ts,js} 中使用的配置选项。
最后,在底层,每个测试都会重用 context 和 page 夹具,作为组件测试的速度优化手段。它在每个测试之间重置它们,因此在功能上应该等同于 @playwright/test 对每个测试提供一个新的、隔离的 context 和 page 夹具的保证。
我有一个已经使用 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 模块必须命名为 *.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,
/**
* Use http intercepting to mock api calls instead:
* https://playwright.net.cn/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('override initialState ', async ({ mount }) => {
const component = await mount<HooksConfig>(Store, {
hooksConfig: {
store: { name: 'override initialState' }
}
});
await expect(component).toContainText('override initialState');
});
如何访问组件的方法或其实例?
既不推荐也不支持在测试代码中访问组件的内部方法或其实例。相反,请专注于从用户的角度观察和与组件交互,通常是通过点击或验证页面上的内容是否可见。当测试避免与内部实现细节(如组件实例或其方法)交互时,它们会变得不那么脆弱,更有价值。请记住,如果测试从用户角度运行时失败,这通常意味着自动化测试已经发现了代码中真正的错误。