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

Core/React: Add testing utilities #17282

Merged
merged 37 commits into from
Apr 1, 2022
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
3d7f249
feat: add testing utilities in @storybook/store
yannbf Jan 19, 2022
a3b599d
feat: add testing utilities in @storybook/react
yannbf Jan 19, 2022
f9f5c02
chore: add tests for testing utilities in cra-ts-essentials
yannbf Jan 19, 2022
d9ac7cb
fix storyStore tests
yannbf Jan 19, 2022
9ccbaac
improve testing utility types
yannbf Jan 19, 2022
bef9e35
test(store): add tests for testing utils
yannbf Jan 19, 2022
7571c0e
chore: make example stories CSF version more explicit in cra-ts-essen…
yannbf Jan 19, 2022
4a606b5
support csf 2 and csf 3 in testing utilities
yannbf Jan 21, 2022
682dfe3
chore(storybook): remove now unnecessary ts-ignores
yannbf Jan 21, 2022
e12ccb1
add jsdoc to testing utilities
yannbf Jan 21, 2022
545b473
refactor(story-store): move normalizeProjectAnnotations to csf
yannbf Jan 24, 2022
6a8e685
refactor: move setGlobalConfig to storybook/store
yannbf Jan 24, 2022
e411f72
refactor: dedupe testing types
yannbf Jan 24, 2022
ef0bb7a
refactor: move logic to getValuesFromArgTypes file
yannbf Jan 24, 2022
800f17d
fix: ensure story has a name before calling composeStory
yannbf Jan 24, 2022
7b7a841
feat: support exportsName in composeStory
yannbf Jan 26, 2022
a647db3
test: update snapshots
yannbf Jan 26, 2022
6da27a4
Merge branch 'next' into feat/testing-utilities
yannbf Mar 2, 2022
f9931a0
Merge branch 'next' into feat/testing-utilities
yannbf Mar 21, 2022
531c2ca
Merge branch 'next' into feat/testing-utilities
yannbf Mar 23, 2022
a4ea848
allow setGlobalConfig to accept array of configurations and compose them
yannbf Mar 23, 2022
c693b4b
remove unnecessary mock
yannbf Mar 28, 2022
bcf7e4d
Move composeConfigs logic from preview-web to store
yannbf Mar 28, 2022
23d2209
Merge branch 'next' into feat/testing-utilities
yannbf Mar 28, 2022
7755bd3
update test and snapshots
yannbf Mar 28, 2022
26767e6
use exports name as story name as fallback
yannbf Mar 28, 2022
ab0e576
add temporary fix for auto title
yannbf Mar 28, 2022
af4e1ab
update yarn lock
yannbf Mar 28, 2022
72bcdf1
Replace setGlobalConfig with setProjectAnnotations
shilman Mar 29, 2022
6a8ec8a
fix build errors in cra-ts-essentials
yannbf Mar 29, 2022
f20128e
Update lib/builder-webpack4/templates/virtualModuleModernEntry.js.han…
yannbf Mar 29, 2022
8a290f8
fix typescript issues
yannbf Mar 29, 2022
0796d7e
use storybook testing library in button stories
yannbf Mar 29, 2022
92df4f3
Merge pull request #17812 from storybookjs/fix/testing-utils-set-proj…
yannbf Mar 29, 2022
2575afd
retrigger CI
yannbf Mar 29, 2022
2d6dfb9
disable chromatic for a couple stories
yannbf Mar 30, 2022
528e0fc
Merge branch 'next' into feat/testing-utilities
shilman Mar 31, 2022
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
1 change: 1 addition & 0 deletions app/react/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
raw,
forceReRender,
} from './preview';
export * from './testing';

export * from './preview/types-6-3';

Expand Down
117 changes: 117 additions & 0 deletions app/react/src/client/testing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {
composeStory as originalComposeStory,
composeStories as originalComposeStories,
setGlobalConfig as originalSetGlobalConfig,
CSFExports,
ComposedStory,
StoriesWithPartialProps,
} from '@storybook/store';
import { ProjectAnnotations, Args } from '@storybook/csf';

import { render } from '../preview/render';
import type { Meta, ReactFramework } from '../preview/types-6-0';

/** 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 projectAnnotations from './.storybook/preview';
*
* setGlobalConfig(projectAnnotations);
*```
*
* @param projectAnnotations - e.g. (import * as projectAnnotations from '../.storybook/preview')
*/
export function setGlobalConfig(
projectAnnotations: ProjectAnnotations<ReactFramework> | ProjectAnnotations<ReactFramework>[]
) {
originalSetGlobalConfig(projectAnnotations);
}

// This will not be necessary once we have auto preset loading
const defaultProjectAnnotations: ProjectAnnotations<ReactFramework> = {
render,
};

/**
* Function that will receive a story along with meta (e.g. a default export from a .stories file)
* and optionally projectAnnotations 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 componentAnnotations - e.g. (import Meta from './Button.stories')
* @param [projectAnnotations] - e.g. (import * as projectAnnotations from '../.storybook/preview') this can be applied automatically if you use `setGlobalConfig` in your setup files.
* @param [exportsName] - in case your story does not contain a name and you want it to have a name.
*/
export function composeStory<TArgs = Args>(
story: ComposedStory<ReactFramework, TArgs>,
componentAnnotations: Meta<TArgs | any>,
projectAnnotations?: ProjectAnnotations<ReactFramework>,
exportsName?: string
) {
return originalComposeStory<ReactFramework, TArgs>(
story,
componentAnnotations,
projectAnnotations,
defaultProjectAnnotations,
exportsName
);
}

/**
* Function that will receive a stories import (e.g. `import * as stories from './Button.stories'`)
* and optionally projectAnnotations (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 csfExports - e.g. (import * as stories from './Button.stories')
* @param [projectAnnotations] - e.g. (import * as projectAnnotations from '../.storybook/preview') this can be applied automatically if you use `setGlobalConfig` in your setup files.
*/
export function composeStories<TModule extends CSFExports<ReactFramework>>(
csfExports: TModule,
projectAnnotations?: ProjectAnnotations<ReactFramework>
) {
const composedStories = originalComposeStories(csfExports, projectAnnotations, composeStory);

return composedStories as unknown as Omit<
StoriesWithPartialProps<ReactFramework, TModule>,
keyof CSFExports
>;
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import React from 'react';
import type { DecoratorFn } from '@storybook/react';
import { ThemeProvider, convert, themes } from '@storybook/theming';

export const decorators = [
(StoryFn, { globals: { locale = 'en' } }) => (
export const decorators: DecoratorFn[] = [
(StoryFn, { globals: { locale } }) => (
<>
<div>{locale}</div>
<div>Locale: {locale}</div>
<StoryFn />
</>
),
(StoryFn) => (
<ThemeProvider theme={convert(themes.light)}>
<StoryFn />
</ThemeProvider>
),
];

export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
};

export const globalTypes = {
locale: {
name: 'Locale',
Expand Down
8 changes: 6 additions & 2 deletions 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 --no-manager-cache",
"test": "react-scripts test"
"test": "SKIP_PREFLIGHT_CHECK=true react-scripts test"
},
"browserslist": {
"production": [
Expand All @@ -26,7 +26,8 @@
"@types/jest": "^26.0.16",
"@types/node": "^14.14.20 || ^16.0.0",
"@types/react": "^16.14.23",
"@types/react-dom": "^16.9.14",
"@types/react-dom": "16.9.14",
"formik": "2.2.9",
"global": "^4.4.0",
"react": "16.14.0",
"react-dom": "16.14.0",
Expand All @@ -38,8 +39,11 @@
"@storybook/addon-ie11": "0.0.7--canary.5e87b64.0",
"@storybook/addons": "6.5.0-alpha.51",
"@storybook/builder-webpack4": "6.5.0-alpha.51",
"@storybook/components": "6.5.0-alpha.51",
"@storybook/preset-create-react-app": "^3.1.6",
"@storybook/react": "6.5.0-alpha.51",
"@storybook/testing-library": "^0.0.9",
"@storybook/theming": "6.5.0-alpha.51",
"webpack": "4"
},
"storybook": {
Expand Down
4 changes: 4 additions & 0 deletions examples/cra-ts-essentials/src/setupTests.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);
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/* eslint-disable storybook/await-interactions */
import React from 'react';
import type { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';

import { AccountForm, AccountFormProps } from './AccountForm';

export default {
title: 'CSF3/AccountForm',
component: AccountForm,
parameters: {
layout: 'centered',
},
} as ComponentMeta<typeof AccountForm>;

type Story = ComponentStoryObj<typeof AccountForm>;

export const Standard: Story = {
args: { passwordVerification: false },
};

export const StandardEmailFilled: Story = {
...Standard,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByTestId('email'), 'michael@chromatic.com');
},
};

export const StandardEmailFailed: Story = {
...Standard,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByTestId('email'), 'michael@chromatic.com.com@com');
await userEvent.type(canvas.getByTestId('password1'), 'testpasswordthatwontfail');
await userEvent.click(canvas.getByTestId('submit'));
},
};

export const StandardPasswordFailed: Story = {
...Standard,
play: async (context) => {
const canvas = within(context.canvasElement);
await StandardEmailFilled.play!(context);
await userEvent.type(canvas.getByTestId('password1'), 'asdf');
await userEvent.click(canvas.getByTestId('submit'));
},
};

export const StandardFailHover: Story = {
...StandardPasswordFailed,
play: async (context) => {
const canvas = within(context.canvasElement);
await StandardPasswordFailed.play!(context);
await sleep(100);
await userEvent.hover(canvas.getByTestId('password-error-info'));
},
};

export const Verification: Story = {
args: { passwordVerification: true },
};

export const VerificationPasssword1: Story = {
...Verification,
play: async (context) => {
const canvas = within(context.canvasElement);
await StandardEmailFilled.play!(context);
await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf');
await userEvent.click(canvas.getByTestId('submit'));
},
};

export const VerificationPasswordMismatch: Story = {
...Verification,
play: async (context) => {
const canvas = within(context.canvasElement);
await StandardEmailFilled.play!(context);
await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf');
await userEvent.type(canvas.getByTestId('password2'), 'asdf1234');
await userEvent.click(canvas.getByTestId('submit'));
},
};

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export const VerificationSuccess: Story = {
...Verification,
play: async (context) => {
const canvas = within(context.canvasElement);
await StandardEmailFilled.play!(context);
await sleep(1000);
await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf', { delay: 50 });
await sleep(1000);
await userEvent.type(canvas.getByTestId('password2'), 'asdfasdf', { delay: 50 });
await sleep(1000);
await userEvent.click(canvas.getByTestId('submit'));
},
};

export const StandardWithRenderFunction: Story = {
...Standard,
render: (args: AccountFormProps) => (
<div>
<p>This uses a custom render</p>
<AccountForm {...args} />
</div>
),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { render, screen } from '@testing-library/react';

import { composeStories, composeStory } from '@storybook/react';

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

const { Standard } = composeStories(stories);

test('renders form', async () => {
await render(<Standard />);
expect(screen.getByTestId('email')).not.toBe(null);
});

test('fills input from play function', async () => {
const StandardEmailFilled = composeStory(stories.StandardEmailFilled, stories.default);
const { container } = await render(<StandardEmailFilled />);

await StandardEmailFilled.play({ canvasElement: container });

const emailInput = screen.getByTestId('email') as HTMLInputElement;
expect(emailInput.value).toBe('michael@chromatic.com');
});
Loading