Skip to main content

组件(实验)

介绍

Introduction

Playwright Test 现在可以测试你的组件。

Playwright Test can now test your components.

示例

Example

典型的组件测试如下所示:

Here is what a typical component test looks like:

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();
});

如何开始

How to get started

将 Playwright 测试添加到现有项目中很容易。以下是为 React、Vue、Svelte 或 Solid 项目启用 Playwright 测试的步骤。

Adding Playwright Test to an existing project is easy. Below are the steps to enable Playwright Test for a React, Vue, Svelte or Solid project.

步骤 1:为你各自的框架安装 Playwright Test 组件

Step 1: Install Playwright Test for components for your respective framework

npm init playwright@latest -- --ct

此步骤在你的工作区中创建多个文件:

This step creates several files in your workspace:

playwright/index.html
<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} 的脚本。

This file defines an html file that will be used to render components during testing. It must contain element with id="root", that's where components are mounted. It must also link the script called playwright/index.{js,ts,jsx,tsx}.

你可以使用此脚本包含样式表、应用主题并将代码注入到安装组件的页面中。它可以是 .js.ts.jsx.tsx 文件。

You can include stylesheets, apply theme and inject code into the page where component is mounted using this script. It can be either a .js, .ts, .jsx or .tsx file.

playwright/index.ts
// Apply theme here, add anything your component needs at runtime here.

步骤 2.创建测试文件 src/App.spec.{ts,tsx}

Step 2. Create a test file src/App.spec.{ts,tsx}

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

test.use({ viewport: { width: 500, height: 500 } });

test('should work', async ({ mount }) => {
const component = await mount(<App />);
await expect(component).toContainText('Learn React');
});

步骤 3.运行测试

Step 3. Run the tests

你可以使用 VS Code 扩展 或命令行运行测试。

You can run tests using the VS Code extension or the command line.

npm run test-ct

进一步阅读:配置报告、浏览器、跟踪

Further reading: configure reporting, browsers, tracing

请参阅 Playwright 配置 配置你的项目。

Refer to Playwright config for configuring your project.

测试故事

Test stories

当 Playwright Test 用于测试 Web 组件时,测试在 Node.js 中运行,而组件在真实浏览器中运行。这汇集了两全其美的优点:组件在真实的浏览器环境中运行,触发真实的点击,执行真实的布局,视觉回归是可能的。同时,测试可以使用 Node.js 的所有功能以及所有 Playwright Test 功能。因此,在组件测试期间可以使用相同的事后跟踪故事进行相同的并行参数化测试。

When Playwright Test is used to test web components, tests run in Node.js, while components run in the real browser. This brings together the best of both worlds: components run in the real browser environment, real clicks are triggered, real layout is executed, visual regression is possible. At the same time, test can use all the powers of Node.js as well as all the Playwright Test features. As a result, the same parallel, parametrized tests with the same post-mortem Tracing story are available during component testing.

然而,这带来了一些限制:

This however, is introducing a number of limitations:

  • 你无法将复杂的活动对象传递给你的组件。只能传递纯 JavaScript 对象和内置类型,如字符串、数字、日期等。

    You can't pass complex live objects to your component. Only plain JavaScript objects and built-in types like strings, numbers, dates etc. can be passed.

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}/>);
});
  • 你无法在回调中将数据同步传递到组件:

    You can't pass data to your component synchronously in a callback:

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'}/>);
});

解决这些和其他限制是快速而优雅的:对于测试组件的每个用例,创建专门为测试设计的该组件的封装器。它不仅可以减轻限制,而且还可以为测试提供强大的抽象,你可以在其中定义组件渲染的环境、主题和其他方面。

Working around these and other limitations is quick and elegant: for every use case of the tested component, create a wrapper of this component designed specifically for test. Not only it will mitigate the limitations, but it will also offer powerful abstractions for testing where you would be able to define environment, theme and other aspects of your component rendering.

假设你想测试以下组件:

Let's say you'd like to test following component:

input-media.tsx
import React from 'react';

export const InputMedia: React.FC<{
// Media is a complex browser object we can't send to Node while testing.
onChange: (media: Media) => void,
}> = ({ onChange }) => {
return <></> as any;
};

为你的组件创建一个故事文件:

Create a story file for your component:

input-media.story.tsx
import React from 'react';
import InputMedia from './import-media';

export const InputMediaForTest: React.FC<{
onMediaChange: (mediaName: string) => void,
}> = ({ onMediaChange }) => {
// Instead of sending a complex `media` object to the test, send the media name.
return <InputMedia onChange={media => onMediaChange(media.name)} />;
};
// Export more stories here.

然后通过测试故事来测试组件:

Then test the component via testing the story:

input-media.test.spec.tsx
test('changes the image', async ({ mount }) => {
let mediaSelected: string | null = null;

const component = await mount(
<InputMediaForTest
onMediaChange={mediaName => {
mediaSelected = mediaName;
console.log({ 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');
});

因此,对于每个组件,你都会有一个故事文件,用于导出实际测试的所有故事。这些故事存在于浏览器中,并将 "convert" 复杂对象转换为可以在测试中访问的简单对象。

As a result, for every component you'll have a story file that exports all the stories that are actually tested. These stories live in the browser and "convert" complex object into the simple objects that can be accessed in the test.

钩子

Hooks

你可以使用 beforeMountafterMount 钩子来配置你的应用。这使你可以设置应用路由、虚假服务器等内容,从而为你提供所需的灵活性。你还可以通过测试的 mount 调用传递自定义配置,可以从 hooksConfig 夹具访问该配置。这包括安装组件之前或之后需要运行的任何配置。下面提供了配置路由的示例:

You can use beforeMount and afterMount hooks to configure your app. This lets you setup things like your app router, fake server etc. giving you the flexibility you need. You can also pass custom configuration from the mount call from a test, which is accessible from the hooksConfig fixture. This includes any config that needs to be run before or after mounting the component. An example of configuring a router is provided below:

playwright/index.tsx
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>;
});
src/pages/ProductsPage.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import type { HooksConfig } from '@playwright/test';
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');
});

在引擎盖下

Under the hood

以下是组件测试的工作原理:

Here is how component testing works:

  • 执行测试后,Playwright 将创建测试所需的组件列表。

    Once the tests are executed, Playwright creates a list of components that the tests need.

  • 然后,它编译一个包含这些组件的包,并使用本地静态 Web 服务器为其提供服务。

    It then compiles a bundle that includes these components and serves it using a local static web server.

  • 在测试中调用 mount 时,Playwright 导航到该包的外观页面 /playwright/index.html 并告诉它渲染组件。

    Upon the mount call within the test, Playwright navigates to the facade page /playwright/index.html of this bundle and tells it to render the component.

  • 事件被编组回 Node.js 环境以进行验证。

    Events are marshalled back to the Node.js environment to allow verification.

Playwright 使用 Vite 创建组件包并为其提供服务。

Playwright is using Vite to create the components bundle and serve it.

常见问题

Frequently asked questions

@playwright/test@playwright/experimental-ct-{react,svelte,vue,solid} 有什么区别?

What's the difference between @playwright/test and @playwright/experimental-ct-{react,svelte,vue,solid}?

test('…', async ({ mount, page, context }) => {
// …
});

@playwright/experimental-ct-{react,svelte,vue,solid} 封装 @playwright/test 以提供额外的内置组件测试特定夹具,称为 mount

@playwright/experimental-ct-{react,svelte,vue,solid} wrap @playwright/test to provide an additional built-in component-testing specific fixture called mount:

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');
});

此外,它还添加了一些可以在 playwright-ct.config.{ts,js} 中使用的配置选项。

Additionally, it adds some config options you can use in your playwright-ct.config.{ts,js}.

最后,在幕后,每个测试都重新使用 contextpage 夹具作为组件测试的速度优化。它会在每次测试之间重置它们,因此它在功能上应该等同于 @playwright/test 的保证,即每次测试你都会获得一个新的、独立的 contextpage 夹具。

Finally, under the hood, each test re-uses the context and page fixture as a speed optimization for Component Testing. It resets them in between each test so it should be functionally equivalent to @playwright/test's guarantee that you get a new, isolated context and page fixture per-test.

我有一个项目已经使用了 Vite。我可以重复使用该配置吗?

I have a project that already uses Vite. Can I reuse the config?

此时,Playwright 与打包程序无关,因此它不会重用你现有的 Vite 配置。你的配置可能有很多我们无法重用的东西。因此,现在,你可以将路径映射和其他高级设置复制到 Playwright 配置的 ctViteConfig 属性中。

At this point, Playwright is bundler-agnostic, so it is not reusing your existing Vite config. Your config might have a lot of things we won't be able to reuse. So for now, you would copy your path mappings and other high level settings into the ctViteConfig property of Playwright config.

import { defineConfig } from '@playwright/experimental-ct-react';

export default defineConfig({
use: {
ctViteConfig: {
// ...
},
},
});

你可以通过 Vite 配置指定插件来进行测试设置。请注意,一旦开始指定插件,你还负责指定框架插件,在本例中为 vue()

You can specify plugins via Vite config for testing settings. Note that once you start specifying plugins, you are responsible for specifying the framework plugin as well, vue() in this case:

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'),
},
},
},
},
});

我该如何使用路由?

How can I use router?

playwright/index.tsx
import { beforeMount, afterMount } from '@playwright/experimental-ct-react/hooks';
import { BrowserRouter } from 'react-router-dom';
import '../src/assets/index.css';

export type HooksConfig = {
routing?: boolean;
}

beforeMount<HooksConfig>(async ({ hooksConfig, App }) => {
console.log(`Before mount: ${JSON.stringify(hooksConfig)}`);

if (hooksConfig?.routing)
return <BrowserRouter><App /></BrowserRouter>;
});

afterMount<HooksConfig>(async () => {
console.log(`After mount`);
});
src/test.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import App from '@/App';
import type { HooksConfig } from '../playwright';

test('navigate to a page by clicking a link', async ({ page, mount }) => {
const component = await mount<HooksConfig>(<App />, {
hooksConfig: { routing: true },
});
await expect(component.getByRole('main')).toHaveText('Login');
await expect(page).toHaveURL('/');
await component.getByRole('link', { name: 'Dashboard' }).click();
await expect(component.getByRole('main')).toHaveText('Dashboard');
await expect(page).toHaveURL('/dashboard');
});

如何测试使用 Pinia 的组件?

How can I test components that uses Pinia?

Pinia 需要在 playwright/index.{js,ts,jsx,tsx} 中初始化。如果你在 beforeMount 钩子内执行此操作,则可以在每次测试的基础上覆盖 initialState

Pinia needs to be initialized in playwright/index.{js,ts,jsx,tsx}. If you do this inside a beforeMount hook, the initialState can be overwritten on a per-test basis:

playwright/index.ts
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.nodejs.cn/docs/mock#mock-api-requests
*/
stubActions: false,
createSpy(args) {
console.log('spy', args)
return () => console.log('spy-returns')
},
});
});
src/pinia.spec.ts
import { test, expect } from '@playwright/experimental-ct-vue';
import type { HooksConfig } from '@playwright/test';
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');
});