Last active
March 22, 2021 14:09
-
-
Save thebuilder/321fdd8dc4842377ce3eb92ef1f5601b to your computer and use it in GitHub Desktop.
Test all Stories in a CSF Storybook
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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