组件(实验)

简介

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 或 Svelte 项目启用 Playwright Test 的步骤。

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

  • npm

  • yarn

  • pnpm

npm init playwright@latest -- --ct
yarn create playwright --ct
pnpm create playwright --ct

此步骤将在工作区中创建几个文件:

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} 的脚本文件。

你可以在此脚本中包含样式表、应用主题并注入代码到页面中,组件会挂载在该页面上。这个脚本可以是 .js.ts.jsx.tsx 文件。

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

步骤 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-svelte';
import App from './App.svelte';

test('should work', async ({ mount }) => {
  const component = await mount(App);
  await expect(component).toContainText('Learn Svelte');
});
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 配置来配置你的项目。

测试故事

当使用 Playwright Test 测试 Web 组件时,测试在 Node.js 中运行,而组件在真实浏览器中运行。这将两者的优势结合起来:组件在真实的浏览器环境中运行,真实的点击被触发,真实的布局被执行,视觉回归是可能的。同时,测试可以使用 Node.js 的所有功能以及 Playwright Test 的所有特性。因此,相同的并行化、参数化测试和后期追踪(Post-mortem Tracing)故事也适用于组件测试。

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

  • 不能将复杂的实时对象传递给组件。只能传递纯 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'}/>);
    });

解决这些和其他限制的方法是快速且优雅的:为每个测试的组件创建一个专门为测试设计的包装器。这样不仅可以缓解这些限制,还能为测试提供强大的抽象,能够定义组件渲染的环境、主题和其他方面。

假设你想测试以下组件:

input-media.tsx
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 文件:

input-media.story.tsx
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.

然后通过测试 story 来测试组件:

input-media.spec.tsx
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 来创建组件包并提供服务。

API 参考

props

在挂载组件时,向组件提供 props

  • React

  • Svelte

  • Vue

component.spec.tsx
import { test } from '@playwright/experimental-ct-react';

test('props', async ({ mount }) => {
  const component = await mount(<Component msg="greetings" />);
});
component.spec.ts
import { test } from '@playwright/experimental-ct-svelte';

test('props', async ({ mount }) => {
  const component = await mount(Component, { props: { msg: 'greetings' } });
});
component.spec.ts
import { test } from '@playwright/experimental-ct-vue';

test('props', async ({ mount }) => {
  const component = await mount(Component, { props: { msg: 'greetings' } });
});
component.spec.tsx
// Or alternatively, using the `jsx` style
import { test } from '@playwright/experimental-ct-vue';

test('props', async ({ mount }) => {
  const component = await mount(<Component msg="greetings" />);
});

callbacks / events

在挂载组件时,向组件提供回调函数或事件。

  • React

  • Svelte

  • Vue

component.spec.tsx
import { test } from '@playwright/experimental-ct-react';

test('callback', async ({ mount }) => {
  const component = await mount(<Component onClick={() => {}} />);
});
component.spec.ts
import { test } from '@playwright/experimental-ct-svelte';

test('event', async ({ mount }) => {
  const component = await mount(Component, { on: { click() {} } });
});
component.spec.ts
import { test } from '@playwright/experimental-ct-vue';

test('event', async ({ mount }) => {
  const component = await mount(Component, { on: { click() {} } });
});
component.spec.tsx
// 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={() => {}} />);
});

children / slots

在挂载组件时,向组件提供 children 或 slots。

  • React

  • Svelte

  • Vue

component.spec.tsx
import { test } from '@playwright/experimental-ct-react';

test('children', async ({ mount }) => {
  const component = await mount(<Component>Child</Component>);
});
component.spec.ts
import { test } from '@playwright/experimental-ct-svelte';

test('slot', async ({ mount }) => {
  const component = await mount(Component, { slots: { default: 'Slot' } });
});
component.spec.ts
import { test } from '@playwright/experimental-ct-vue';

test('slot', async ({ mount }) => {
  const component = await mount(Component, { slots: { default: 'Slot' } });
});
component.spec.tsx
// 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

你可以使用 beforeMountafterMount 钩子来配置应用程序。这样可以设置一些如应用路由器、虚拟服务器等的内容,给你所需的灵活性。你还可以通过测试中的 mount 调用传递自定义配置,并通过 hooksConfig fixture 访问。这些配置包括在挂载组件之前或之后需要运行的配置。例如,下面是配置路由器的示例:

  • React

  • Vue3

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>;
});
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');
});
playwright/index.ts
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);
});
src/pages/ProductsPage.spec.ts
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

  • Svelte

  • Vue

component.spec.tsx
import { test } from '@playwright/experimental-ct-react';

test('unmount', async ({ mount }) => {
  const component = await mount(<Component/>);
  await component.unmount();
});
component.spec.ts
import { test } from '@playwright/experimental-ct-svelte';

test('unmount', async ({ mount }) => {
  const component = await mount(Component);
  await component.unmount();
});
component.spec.ts
import { test } from '@playwright/experimental-ct-vue';

test('unmount', async ({ mount }) => {
  const component = await mount(Component);
  await component.unmount();
});
component.spec.tsx
// 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、children/slots 或 callbacks/events。这些组件输入可以随时变化,通常由父组件提供,但有时需要确保组件能够正确地响应新的输入。

  • React

  • Svelte

  • Vue

component.spec.tsx
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>
  );
});
component.spec.ts
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' }
  });
});
component.spec.ts
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' }
  });
});
component.spec.tsx
// 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 fixture 来拦截和处理网络请求。你可以通过两种方式使用这个路由器 fixture:

  • 调用 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,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-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');
});
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} 中使用的配置选项。

底层原理是,每个测试都重新使用 contextpage fixture 以加快组件测试的速度优化,并且在每个测试之间重置它们,因此它与 @playwright/test 的保证是相同的,每个测试会得到一个新的、隔离的 contextpage 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'),
        },
      },
    },
  },
});

如何测试使用 Pinia 的组件?

playwright/index.{js,ts,jsx,tsx} 中初始化 Pinia。如果在 beforeMount 钩子中执行这一步,initialState 可以在每个测试中被重写:

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.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('override initialState ', async ({ mount }) => {
  const component = await mount<HooksConfig>(Store, {
    hooksConfig: {
      store: { name: 'override initialState' }
    }
  });
  await expect(component).toContainText('override initialState');
});

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

在测试代码中,访问组件的内部方法或实例既不推荐也不被支持。应专注于从用户的角度观察和交互组件,通常是通过点击或验证页面上某些内容是否可见。当测试避免与内部实现细节(如组件实例或方法)交互时,测试会更加健壮,且更具价值。如果测试在用户角度失败,那么这很可能意味着自动化测试已经发现了代码中的真实错误。