diff --git a/.storybook/test-runner.ts b/.storybook/test-runner.ts new file mode 100644 index 00000000..f291980f --- /dev/null +++ b/.storybook/test-runner.ts @@ -0,0 +1,21 @@ +import { toMatchImageSnapshot } from 'jest-image-snapshot'; +import type { TestRunnerConfig } from '../dist/ts'; + +const customSnapshotsDir = `${process.cwd()}/__snapshots__`; + +const config: TestRunnerConfig = { + setup() { + expect.extend({ toMatchImageSnapshot }); + }, + async postRender(page, context) { + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot({ + customSnapshotsDir, + customSnapshotIdentifier: context.id, + failureThreshold: 0.03, + failureThresholdType: 'percent', + }); + }, +}; + +export default config; diff --git a/README.md b/README.md index 069aa8d2..5b667aca 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,25 @@ -# Storybook Test Runner +

Storybook Test Runner

Storybook test runner turns all of your stories into executable tests. -## Table of Contents - -- [Storybook Test Runner](#storybook-test-runner) - - [Table of Contents](#table-of-contents) - - [Features](#features) - - [Getting started](#getting-started) - - [CLI Options](#cli-options) - - [Configuration](#configuration) - - [Running against a deployed Storybook](#running-against-a-deployed-storybook) - - [Stories.json mode](#storiesjson-mode) - - [Running in CI](#running-in-ci) - - [1. Running against deployed Storybooks on Github Actions deployment](#1-running-against-deployed-storybooks-on-github-actions-deployment) - - [2. Running against locally built Storybooks in CI](#2-running-against-locally-built-storybooks-in-ci) - - [Experimental test hook API](#experimental-test-hook-api) - - [Image snapshot recipe](#image-snapshot-recipe) - - [Troubleshooting](#troubleshooting) +

Table of Contents

+ +- [Features](#features) +- [Getting started](#getting-started) +- [CLI Options](#cli-options) +- [Configuration](#configuration) +- [Running against a deployed Storybook](#running-against-a-deployed-storybook) + - [Stories.json mode](#storiesjson-mode) +- [Running in CI](#running-in-ci) + - [1. Running against deployed Storybooks on Github Actions deployment](#1-running-against-deployed-storybooks-on-github-actions-deployment) + - [2. Running against locally built Storybooks in CI](#2-running-against-locally-built-storybooks-in-ci) +- [Experimental test hook API](#experimental-test-hook-api) + - [Image snapshot recipe](#image-snapshot-recipe) + - [Render lifecycle](#render-lifecycle) +- [Troubleshooting](#troubleshooting) - [The test runner seems flaky and keeps timing out](#the-test-runner-seems-flaky-and-keeps-timing-out) - [Adding the test runner to other CI environments](#adding-the-test-runner-to-other-ci-environments) - - [Future work](#future-work) +- [Future work](#future-work) ## Features @@ -255,10 +254,42 @@ The test runner renders a story and executes its [play function](https://storybo To enable use cases like visual or DOM snapshots, the test runner exports test hooks that can be overridden globally. These hooks give you access to the test lifecycle before and after the story is rendered. -The hooks, `preRender` and `postRender`, are functions that take a [Playwright Page](https://playwright.dev/docs/pages) and a context object with the current story `id`, `title`, and `name`. They are globally settable by `@storybook/test-runner`'s `setPreRender` and `setPostRender` APIs. +There are three hooks: `setup`, `preRender`, and `postRender`. `setup` executes once before all the tests run. `preRender` and `postRender` execute within a test before and after a story is rendered. + +The render functions are async functions that receive a [Playwright Page](https://playwright.dev/docs/pages) and a context object with the current story `id`, `title`, and `name`. They are globally settable by `@storybook/test-runner`'s `setPreRender` and `setPostRender` APIs. + +All three functions can be set up in the configuration file `.storybook/test-runner.js` which can optionally export any of these functions. > **NOTE:** These test hooks are experimental and may be subject to breaking changes. We encourage you to test as much as possible within the story's play function. +### Image snapshot recipe + +Consider, for example, the following recipe to take image snapshots: + +```js +// .storybook/test-runner.js +const { toMatchImageSnapshot } = require('jest-image-snapshot'); + +const customSnapshotsDir = `${process.cwd()}/__snapshots__`; + +module.exports = { + setup() { + expect.extend({ toMatchImageSnapshot }); + }, + async postRender(page, context) { + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot({ + customSnapshotsDir, + customSnapshotIdentifier: context.id, + }); + }, +}; +``` + +There is also an exported `TestRunnerConfig` type available for TypeScript users. + +### Render lifecycle + To visualize the test lifecycle, consider a simplified version of the test code automatically generated for each story in your Storybook: ```js @@ -280,28 +311,6 @@ it('button--basic', async () => { }); ``` -### Image snapshot recipe - -If you want to make the test runner take image snapshots, the following recipe uses test hooks in `jest-setup.js` to do it: - -```js -const { toMatchImageSnapshot } = require('jest-image-snapshot'); -const { setPostRender } = require('@storybook/test-runner'); - -expect.extend({ toMatchImageSnapshot }); - -// use custom directory/id to align CSF and stories.json mode outputs -const customSnapshotsDir = `${process.cwd()}/__snapshots__`; - -setPostRender(async (page, context) => { - const image = await page.screenshot(); - expect(image).toMatchImageSnapshot({ - customSnapshotsDir, - customSnapshotIdentifier: context.id, - }); -}); -``` - ## Troubleshooting #### The test runner seems flaky and keeps timing out diff --git a/jest-setup.js b/jest-setup.js deleted file mode 100644 index 99feca39..00000000 --- a/jest-setup.js +++ /dev/null @@ -1,17 +0,0 @@ -const { toMatchImageSnapshot } = require('jest-image-snapshot'); -const { setPostRender } = require('./dist/cjs'); - -expect.extend({ toMatchImageSnapshot }); - -// use custom directory/id to align CSF and stories.json mode outputs -const customSnapshotsDir = `${process.cwd()}/__snapshots__`; - -setPostRender(async (page, context) => { - const image = await page.screenshot(); - expect(image).toMatchImageSnapshot({ - customSnapshotsDir, - customSnapshotIdentifier: context.id, - failureThreshold: 0.03, - failureThresholdType: 'percent', - }); -}); diff --git a/playwright/jest-setup.js b/playwright/jest-setup.js new file mode 100644 index 00000000..3e59b20f --- /dev/null +++ b/playwright/jest-setup.js @@ -0,0 +1,14 @@ +const { getTestRunnerConfig, setPreRender, setPostRender } = require('../dist/cjs'); + +const testRunnerConfig = getTestRunnerConfig(process.env.STORYBOOK_CONFIG_DIR); +if (testRunnerConfig) { + if (testRunnerConfig.setup) { + testRunnerConfig.setup(); + } + if (testRunnerConfig.preRender) { + setPreRender(testRunnerConfig.preRender); + } + if (testRunnerConfig.postRender) { + setPostRender(testRunnerConfig.postRender); + } +} diff --git a/src/config/jest-playwright.ts b/src/config/jest-playwright.ts index f6c65302..59b68117 100644 --- a/src/config/jest-playwright.ts +++ b/src/config/jest-playwright.ts @@ -13,7 +13,7 @@ export const getJestConfig = () => { globalSetup: '@storybook/test-runner/playwright/global-setup.js', globalTeardown: '@storybook/test-runner/playwright/global-teardown.js', testEnvironment: '@storybook/test-runner/playwright/custom-environment.js', - // @TODO: setupFilesAfterEnv: ['@storybook/test-runner/setup'] + setupFilesAfterEnv: ['@storybook/test-runner/playwright/jest-setup.js'], }; if (TEST_MATCH) { diff --git a/src/index.ts b/src/index.ts index 0ce45489..d7ffe9f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export * from './playwright/hooks'; export * from './config/jest-playwright'; +export * from './util/getTestRunnerConfig'; diff --git a/src/playwright/hooks.ts b/src/playwright/hooks.ts index 0b8b7d6d..518a9b91 100644 --- a/src/playwright/hooks.ts +++ b/src/playwright/hooks.ts @@ -9,6 +9,12 @@ export type TestContext = { export type TestHook = (page: Page, context: TestContext) => Promise; +export interface TestRunnerConfig { + setup?: () => void; + preRender?: TestHook; + postRender?: TestHook; +} + export const setPreRender = (preRender: TestHook) => { global.__sbPreRender = preRender; }; diff --git a/src/util/getTestRunnerConfig.ts b/src/util/getTestRunnerConfig.ts new file mode 100644 index 00000000..c8ffe7bf --- /dev/null +++ b/src/util/getTestRunnerConfig.ts @@ -0,0 +1,17 @@ +import { join, resolve } from 'path'; +import { serverRequire } from '@storybook/core-common'; +import { TestRunnerConfig } from '../playwright/hooks'; + +let testRunnerConfig: TestRunnerConfig; +let loaded = false; + +export const getTestRunnerConfig = (configDir: string): TestRunnerConfig | undefined => { + // testRunnerConfig can be undefined + if (loaded) { + return testRunnerConfig; + } + + testRunnerConfig = serverRequire(join(resolve(configDir), 'test-runner')); + loaded = true; + return testRunnerConfig; +}; diff --git a/src/util/index.ts b/src/util/index.ts index e6a3578a..a8b5e3d1 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,4 +1,5 @@ export * from './getCliOptions'; +export * from './getTestRunnerConfig'; export * from './getStorybookMain'; export * from './getStorybookMetadata'; export * from './getParsedCliOptions'; diff --git a/test-runner-jest.config.js b/test-runner-jest.config.js index 3a05521e..94993cee 100644 --- a/test-runner-jest.config.js +++ b/test-runner-jest.config.js @@ -13,5 +13,5 @@ module.exports = { globalSetup: './playwright/global-setup.js', globalTeardown: './playwright/global-teardown.js', testEnvironment: './playwright/custom-environment.js', - setupFilesAfterEnv: ['/jest-setup.js'], + setupFilesAfterEnv: ['./playwright/jest-setup.js'], };