Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React: Add testing utilities #12959

Closed
wants to merge 18 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@babel/preset-react": "^7.12.10",
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.2",
"@storybook/addons": "6.2.0-alpha.25",
"@storybook/client-api": "6.2.0-alpha.25",
"@storybook/core": "6.2.0-alpha.25",
"@storybook/node-logger": "6.2.0-alpha.25",
"@storybook/semver": "^7.3.2",
Expand All @@ -66,7 +67,6 @@
"webpack": "^4.46.0"
},
"devDependencies": {
"@storybook/client-api": "6.2.0-alpha.25",
"@types/node": "^14.14.20",
"@types/prompts": "^2.0.9",
"@types/webpack": "^4.41.26"
Expand Down
1 change: 1 addition & 0 deletions app/react/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
} from './preview';

export * from './preview/types-6-0';
export * from './preview/utils';

if (module && module.hot && module.hot.decline) {
module.hot.decline();
Expand Down
23 changes: 22 additions & 1 deletion app/react/src/client/preview/types-6-0.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { ComponentType } from 'react';
import { Args as DefaultArgs, Annotations, BaseMeta, BaseStory } from '@storybook/addons';
import {
Args as DefaultArgs,
Annotations,
ArgTypes,
Parameters,
BaseMeta,
BaseStory,
BaseDecorators,
} from '@storybook/addons';
import { StoryFnReactReturnType } from './types';

export type { Args, ArgTypes, Parameters, StoryContext } from '@storybook/addons';
Expand All @@ -22,3 +30,16 @@ export type Meta<Args = DefaultArgs> = BaseMeta<ReactComponent> &
*/
export type Story<Args = DefaultArgs> = BaseStory<Args, ReactReturnType> &
Annotations<Args, ReactReturnType>;

/**
* Object representing the preview.ts module
*
* Used in storybook testing utilities.
* @see [Unit testing with Storybook](https://storybook.js.org/docs/react/workflows/unit-testing)
*/
export type GlobalConfig = {
decorators?: BaseDecorators<ReactReturnType>;
parameters?: Parameters;
argTypes?: ArgTypes;
[key: string]: any;
};
133 changes: 133 additions & 0 deletions app/react/src/client/preview/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { defaultDecorateStory, combineParameters } from '@storybook/client-api';
import { GlobalConfig, Meta, Story, StoryContext } from './types-6-0';

let globalStorybookConfig = {};

/** Function that sets the globalConfig of your storybook. The global config is the preview module of your .storybook folder.
*
* It should be run a single time, so that your global config (e.g. decorators) is applied to your stories when using `composeStories` or `composeStory`.
*
* Example:
*```jsx
* // setup.js (for jest)
* import { setGlobalConfig } from '@storybook/react';
* import * as globalStorybookConfig from './.storybook/preview';
*
* setGlobalConfig(globalStorybookConfig);
*```
*
* @param config - e.g. (import * as globalConfig from '../.storybook/preview')
*/
export function setGlobalConfig(config: GlobalConfig) {
globalStorybookConfig = config;
}

/**
* Function that will receive a story along with meta (e.g. a default export from a .stories file)
* and optionally a globalConfig e.g. (import * from '../.storybook/preview)
* and will return a composed component that has all args/parameters/decorators/etc combined and applied to it.
*
*
* It's very useful for reusing a story in scenarios outside of Storybook like unit testing.
*
* Example:
*```jsx
* import { render } from '@testing-library/react';
* import { composeStory } from '@storybook/react';
* import Meta, { Primary as PrimaryStory } from './Button.stories';
*
* const Primary = composeStory(PrimaryStory, Meta);
*
* test('renders primary button with Hello World', () => {
* const { getByText } = render(<Primary>Hello world</Primary>);
* expect(getByText(/Hello world/i)).not.toBeNull();
* });
*```
*
* @param story
* @param meta - e.g. (import Meta from './Button.stories')
* @param [globalConfig] - e.g. (import * as globalConfig from '../.storybook/preview') this can be applied automatically if you use `setGlobalConfig` in your setup files.
*/
export function composeStory<GenericArgs>(
story: Story<GenericArgs>,
meta: Meta,
globalConfig: GlobalConfig = globalStorybookConfig
) {
const finalStoryFn = (context: StoryContext) => {
const { passArgsFirst = true } = context.parameters;
if (!passArgsFirst) {
throw new Error(
'composeStory does not support legacy style stories (with passArgsFirst = false).'
);
}
return story(context.args as GenericArgs, context);
Comment on lines +58 to +63
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!passArgsFirst) {
throw new Error(
'composeStory does not support legacy style stories (with passArgsFirst = false).'
);
}
return story(context.args as GenericArgs, context);
return passArgsFirst ? story(context.args as GenericArgs, context) : story(context);

Was there a reason not to just do this? I'm not sure if it is a big deal to support non-passArgsFirst but this is simple enough?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO we shouldn't support legacy CSF on new features. if you want to use it, migrate!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know if we should resolve this conversation :D
I'm happy to do either way

};

const combinedDecorators = [
...(story.decorators || story.story?.decorators || []),
...(meta?.decorators || []),
...(globalConfig?.decorators || []),
];

const decorated = defaultDecorateStory(finalStoryFn, combinedDecorators);

return ((extraArgs: Record<string, any>) =>
decorated({
id: '',
kind: '',
name: '',
argTypes: globalConfig.argTypes,
globals: globalConfig.globalTypes,
parameters: combineParameters(
globalConfig.parameters || {},
meta.parameters || {},
story.story?.parameters || {},
story.parameters || {}
),
args: {
...meta.args,
...story.story?.args,
...story.args,
...extraArgs,
},
})) as Story<Partial<GenericArgs>>;
}

/**
* Function that will receive a stories import (e.g. `import * as stories from './Button.stories'`)
* and optionally a globalConfig (e.g. `import * from '../.storybook/preview`)
* and will return an object containing all the stories passed, but now as a composed component that has all args/parameters/decorators/etc combined and applied to it.
*
*
* It's very useful for reusing stories in scenarios outside of Storybook like unit testing.
*
* Example:
*```jsx
* import { render } from '@testing-library/react';
* import { composeStories } from '@storybook/react';
* import * as stories from './Button.stories';
*
* const { Primary, Secondary } = composeStories(stories);
*
* test('renders primary button with Hello World', () => {
* const { getByText } = render(<Primary>Hello world</Primary>);
* expect(getByText(/Hello world/i)).not.toBeNull();
* });
*```
*
* @param storiesImport - e.g. (import * as stories from './Button.stories')
* @param [globalConfig] - e.g. (import * as globalConfig from '../.storybook/preview') this can be applied automatically if you use `setGlobalConfig` in your setup files.
*/
export function composeStories<T extends { default: Meta } & { [K in keyof T]: T[K] }>(
storiesImport: T,
globalConfig?: GlobalConfig
) {
const { default: meta, ...stories } = storiesImport;

// Compose an object containing all processed stories passed as parameters
return Object.entries(stories).reduce((storiesMap, [key, story]: [string, Story]) => {
// eslint-disable-next-line no-param-reassign
storiesMap[key] = composeStory(story, meta, globalConfig);
return storiesMap;
}, {} as { [key: string]: Story }) as Pick<T, Exclude<keyof T, 'default'>>;
}
7 changes: 7 additions & 0 deletions docs/snippets/react/button-test-utility-setup.js.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
```js
// setupFile.js
import { setGlobalConfig } from '@storybook/react';
import * as globalStorybookConfig from './.storybook/preview';

setGlobalConfig(globalStorybookConfig);
```
16 changes: 16 additions & 0 deletions docs/snippets/react/button-test-utility.js.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
```js
// Button.test.js

import React from 'react';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import * as stories from './Button.stories';
import '@testing-library/jest-dom/extend-expect';

const { Primary } = composeStories(stories);

it('renders the button in the primary state with custom text', () => {
render(<Primary>Hello world</Primary>);
expect(screen.getByRole('button')).toHaveTextContent('Hello world');
});
```
30 changes: 30 additions & 0 deletions docs/workflows/unit-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,34 @@ Here is an example of how you can use it in a testing library:

<!-- prettier-ignore-end -->

## Testing more complex components

When your components use functionality from tools like [styled components](http://styled-components.com/) or [redux](https://redux.js.org/), it's necessary to wrap your stories in [decorators](../writing-stories/decorators#story-decorators) so that the extra functionality is available in your stories. If that is the case, you can use a testing utility (currently only available in Storybook for React) to reuse your stories in your tests, and all the decorators, parameters, and args of that story will be automatically applied in the component. This makes your test file look quite clean and just reuse the configuration done in the Storybook side, making the maintenance of your testing setup simpler.

Here is an example of how you can use it in a testing library:

<!-- prettier-ignore-start -->

<CodeSnippets
paths={[
'react/button-test-utility.js.mdx',
]}
/>

<!-- prettier-ignore-end -->

In case you have a global configuration for your stories (such as decorators in your preview.js file), they won't be picked up automatically in the code above. For that, you need to tell Storybook what is your global configuration, before running any code with `composeStories`:

<!-- prettier-ignore-start -->

<CodeSnippets
paths={[
'react/button-test-utility-setup.js.mdx',
]}
/>

<!-- prettier-ignore-end -->

If you're using jest, you can configure on a [setup file](https://jestjs.io/docs/en/configuration#setupfiles-array), otherwise, just make sure the configuration runs only once and before you execute `composeStories`.

Unit tests can be brittle and expensive to maintain for _every_ component. We recommend combining unit tests with other testing methods like [visual regression testing](./visual-testing.md) for comprehensive coverage with less maintenance work.
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { Story, DecoratorFn } from '@storybook/react';
import React from 'react';

export const decorators = [
(StoryFn, { globals: { locale = 'en' } }) => (
export const decorators: DecoratorFn[] = [
(
StoryFn: Story,
{
globals: {
locale: { value = 'en' },
},
}
) => (
<>
<div>{locale}</div>
<div>{value}</div>
<StoryFn />
</>
),
Expand Down
2 changes: 1 addition & 1 deletion examples/cra-ts-essentials/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"eject": "react-scripts eject",
"start": "react-scripts start",
"storybook": "start-storybook -p 9009 -s public",
"test": "react-scripts test"
"test": "react-scripts test --setupFiles ./setup.ts"
},
"browserslist": {
"production": [
Expand Down
4 changes: 4 additions & 0 deletions examples/cra-ts-essentials/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { setGlobalConfig } from '@storybook/react';
import * as globalStorybookConfig from './.storybook/preview';

setGlobalConfig(globalStorybookConfig);
20 changes: 0 additions & 20 deletions examples/cra-ts-essentials/src/stories/1-Button.stories.tsx

This file was deleted.

9 changes: 6 additions & 3 deletions examples/cra-ts-essentials/src/stories/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ export default {
const Template: Story<ButtonProps> = (args) => <Button {...args} />;

export const Primary = Template.bind({});

Primary.args = {
label: 'foo',
children: 'foo',
size: 'large',
};

// export const Secondary = Template.bind({});
export const Secondary = Template.bind({});
Secondary.args = {
children: 'Children coming from story args!',
primary: false,
};
42 changes: 42 additions & 0 deletions examples/cra-ts-essentials/src/stories/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';
import { render } from '@testing-library/react';
import { composeStories, composeStory } from '@storybook/react';

import * as stories from './Button.stories';

// example with composeStories, returns an object with all stories composed with args/decorators
const { Primary } = composeStories(stories);

// example with composeStory, returns a single story composed with args/decorators
const Secondary = composeStory(stories.Secondary, stories.default);

test('renders primary button', () => {
const { getByText } = render(<Primary>Hello world</Primary>);
const buttonElement = getByText(/Hello world/i);
expect(buttonElement).not.toBeNull();
});

test('reuses args from composed story', () => {
const { getByText } = render(<Secondary />);
const buttonElement = getByText(/Children coming from story args!/i);
expect(buttonElement).not.toBeNull();
});

test('onclick handler is called', async () => {
const onClickSpy = jest.fn();
const { getByRole } = render(<Secondary onClick={onClickSpy} />);
const buttonElement = getByRole('button');
buttonElement.click();
expect(onClickSpy).toHaveBeenCalled();
});

/**
* FIXME: this would only work in typescript projects when composeStories returns type Story<Partial<Props>>.
* It breaks now because Primary contains non-optional props.
* */
// eslint-disable-next-line jest/no-commented-out-tests
// test('reuses args from composeStories', () => {
// const { getByText } = render(<Primary />);
// const buttonElement = getByText(/foo/i);
// expect(buttonElement).not.toBeNull();
// });
Loading