docs: recommend using test stories in ct (#27440)

Closes https://github.com/microsoft/playwright/issues/27439
This commit is contained in:
Pavel Feldman 2023-10-04 19:48:26 -07:00 committed by GitHub
parent 0eb7a09967
commit 25f06575bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 2174 additions and 98 deletions

View file

@ -1,6 +1,6 @@
---
id: test-components
title: "Experimental: components"
title: "Components (experimental)"
---
import LiteYouTube from '@site/src/components/LiteYouTube';
@ -193,6 +193,92 @@ npm run test-ct
Refer to [Playwright config](./test-configuration.md) for configuring your project.
## Test stories
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:
- 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.
```js
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:
```js
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:
```js title="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:
```js title="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:
```js title="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');
});
```
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
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:
@ -337,9 +423,7 @@ You can use `beforeMount` and `afterMount` hooks to configure your app. This let
## Under the hood
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.
Here is how this is achieved:
Here is how component testing works:
- Once the tests are executed, Playwright creates a list of components that the tests need.
- It then compiles a bundle that includes these components and serves it using a local static web server.

2166
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -49,6 +49,7 @@
"devDependencies": {
"@actions/core": "^1.10.0",
"@babel/cli": "^7.19.3",
"@babel/code-frame": "^7.22.13",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-proposal-export-namespace-from": "^7.18.9",
"@babel/plugin-proposal-logical-assignment-operators": "^7.18.9",
@ -83,6 +84,7 @@
"esbuild": "^0.18.11",
"eslint": "^8.37.0",
"eslint-plugin-notice": "^0.9.10",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.3.0",
"formidable": "^2.1.1",
"license-checker": "^25.0.1",

View file

@ -26,6 +26,7 @@ const { ESLint } = require('eslint')
const child_process = require('child_process');
const os = require('os');
const actions = require('@actions/core')
const { codeFrameColumns } = require('@babel/code-frame');
/** @typedef {import('../documentation').Type} Type */
/** @typedef {import('../../markdown').MarkdownNode} MarkdownNode */
@ -139,6 +140,13 @@ class JSLintingService extends LintingService {
overrideConfigFile: path.join(PROJECT_DIR, '.eslintrc.js'),
useEslintrc: false,
overrideConfig: {
plugins: ['react'],
settings: {
react: { version: 'detect', }
},
extends: [
'plugin:react/recommended',
],
rules: {
'notice/notice': 'off',
'@typescript-eslint/no-unused-vars': 'off',
@ -162,7 +170,9 @@ class JSLintingService extends LintingService {
const results = await this.eslint.lintText(snippet.code);
if (!results || !results.length || !results[0].messages.length)
return { status: 'ok' };
return { status: 'error', error: results[0].messages[0].message };
const result = results[0];
const error = result.source ? results[0].messages[0].message + '\n\n' + codeFrameColumns(result.source, { start: result.messages[0] }, { highlightCode: true }) : results[0].messages[0].message;
return { status: 'error', error };
}
/**