Skip to content

Instantly share code, notes, and snippets.

@thebuilder
Last active March 22, 2021 14:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thebuilder/321fdd8dc4842377ce3eb92ef1f5601b to your computer and use it in GitHub Desktop.
Save thebuilder/321fdd8dc4842377ce3eb92ef1f5601b to your computer and use it in GitHub Desktop.
Test all Stories in a CSF Storybook
/* eslint-disable jest/valid-title */
import React from 'react';
import { render, RenderResult, waitFor } from '@testing-library/react';
import chalk from 'chalk';
import globby from 'globby';
import path from 'path';
import {
IncludeExcludeOptions,
isExportStory,
storyNameFromExport,
} from '@storybook/csf';
import { DecoratorFn, Meta } from '@storybook/react';
interface StoryCallback {
storyName: string;
pathName: string;
meta: Meta;
}
interface TestStoriesOptions {
/**
* Provide a custom render method, instead of the default from @testing-library/react.
* This is used to apply a fixed set of decorators around all your stories.
* [https://testing-library.com/docs/react-testing-library/setup#custom-render]()
* */
customRender?: (ui: React.ReactElement) => RenderResult;
/**
* Storybook style decorators to wrap around each story.
* [https://storybook.js.org/docs/react/writing-stories/decorators]()
* */
decorators?: DecoratorFn[];
/**
* Callback after each `render()`. Use this if you need to perform custom validation.
* If not defined, a default `await waitFor` call will be made, to ensure stories are fully loaded.
* */
callback?: (params: StoryCallback) => Promise<void>;
}
testStories('./src/**/*.{story,stories}.tsx', {
customRender: render,
decorators: [
(fn) => {
return <div>{fn()}</div>;
},
],
});
function testStories(
storiesGlob: string | string[],
options: TestStoriesOptions = {}
) {
// Find all our relevant CSF stories.
const stories = globby.sync(storiesGlob);
/**
* Map all our stories and perform a test render.
* This ensures all our stories can render, and aren't dependent on external data.
* */
stories.forEach((pathName) => {
const requirePath = path.join(process.cwd(), pathName);
const data = require(requirePath);
// Make sure this file is a valid CSF story
if (data.default?.title) {
// We use describe here so we know which file the tests are related to.
describe(pathName, () => {
test.each(prepareStories(data, options))(
chalk`{grey ${data.default.title}} can render {cyan "%s"}`,
async (storyName, render) => {
render();
if (options.callback) {
await options.callback({
storyName,
pathName,
meta: data.default as Meta,
});
} else {
// eslint-disable-next-line testing-library/no-wait-for-empty-callback
await waitFor(() => {
// Wait for the render to be complete, so we handle data being loaded etc.
});
}
}
);
});
}
});
}
/**
* Find the stories in a CSF storybook file, removing the `default` and respecting
* the include/exclude options defined on `default`.
* */
function filterStories(stories: { default: IncludeExcludeOptions | Object }) {
return Object.keys(stories).filter(
(name) =>
// @ts-ignore
typeof stories[name] === 'function' &&
isExportStory(name, stories.default as IncludeExcludeOptions)
);
}
/**
* Prepare the stories from a CSF storybook file to be rendered using `test.each`.
* It returns an Array containing a name and render method for each story.
**/
function prepareStories(
stories: {
default: IncludeExcludeOptions & {
title: string;
component?: any;
parameters?: any;
decorators?: Array<
(storyFn: () => React.ReactElement, context: any) => React.ReactElement
>;
};
},
options: TestStoriesOptions
) {
const { decorators } = stories.default;
return filterStories(stories).map((key) => {
// Get a pretty name from the story, that we can output instead of the key.
const name = storyNameFromExport(key);
return [
name,
// Create a render method we can call later, when we want to do the actual rendering.
() => {
// @ts-ignore
const story = stories[key];
const storyDecorators = decorators
? [
...(options.decorators ?? []),
...decorators,
...(story.decorators ?? []),
]
: story.decorators;
// Prepare the Story element first
// @ts-ignore
let output: React.ReactElement = React.createElement(stories[key]);
if (storyDecorators) {
// Wrap the Story with any decorators that's defined on CSF story, or the individual story
storyDecorators.forEach((decorator: DecoratorFn) => {
try {
output = decorator(() => output, story);
} catch (e) {
// Failed to wrap the decorator. Might be `withKnobs`?
// eslint-disable-next-line no-console
console.warn(
'Failed to create decorator. You can exclude the decorators from tests:\n' +
"decorators: process.env.NODE_ENV !== 'test' ? [withKnobs] : undefined\n\n" +
e.stack
);
}
});
}
// Render using our custom @testing-library/react render method
return (options.customRender ?? render)(output);
},
] as [string, () => void];
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment