默认情况下,测试运行器提供运行多个测试模式(例如,DOM 快照测试、可访问性)的选项,只需最少的配置。但是,如果需要,可以扩展它来运行视觉回归测试以及其他测试。例如
.storybook/test-runner.ts
import { TestRunnerConfig, waitForPageReady } from '@storybook/test-runner';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
const customSnapshotsDir = `${process.cwd()}/__snapshots__`;
const config: TestRunnerConfig = {
setup() {
expect.extend({ toMatchImageSnapshot });
},
async postVisit(page, context) {
// Waits for the page to be ready before taking a screenshot to ensure consistent results
await waitForPageReady(page);
// To capture a screenshot for for different browsers, add page.context().browser().browserType().name() to get the browser name to prefix the file name
const image = await page.screenshot();
expect(image).toMatchImageSnapshot({
customSnapshotsDir,
customSnapshotIdentifier: context.id,
});
},
};
export default config;
Storybook 提供了一个 composeStories
工具,该工具有助于将故事从故事文件转换为可渲染元素,这些元素可以在 Node 测试中与 JSDOM 一起重用。它还允许您应用在项目中启用的其他 Storybook 功能(例如,装饰器、参数),这将使您的组件正确渲染。这就是所谓的可移植故事。
建议您关闭当前的 storyshots 测试以开始迁移过程。为此,将配置文件(例如,storybook.test.ts|js
或类似文件)重命名为 storybook.test.ts|js.old
。这将阻止测试被检测到,因为您将创建一个具有相同名称的新测试配置文件。这样做,您将能够在从项目中删除 Storyshots 附加组件之前,保留现有的测试,同时过渡到可移植故事。
如果需要在测试中启用项目级注释(例如,装饰器、参数、样式)在您的 ./storybook/preview.js|ts
中,请调整您的测试设置文件以按如下方式导入注释。
import { beforeAll } from 'vitest';
// 👇 If you're using Next.js, import from @storybook/nextjs
// If you're using Next.js with Vite, import from @storybook/experimental-nextjs-vite
import { setProjectAnnotations } from '@storybook/react';
// 👇 Import the exported annotations, if any, from the addons you're using; otherwise remove this
import * as addonAnnotations from 'my-addon/preview';
import * as previewAnnotations from './.storybook/preview';
const annotations = setProjectAnnotations([previewAnnotations, addonAnnotations]);
// Run Storybook's beforeAll hook
beforeAll(annotations.beforeAll);
为了帮助您从 Storyshots 附加组件迁移到 Storybook 的可移植故事,并使用 composeStories
帮助器 API,我们准备了一些示例来帮助您入门。下面列出了两个最流行的测试框架的示例:Jest 和 Vitest。建议将代码放在新创建的 storybook.test.ts|js
文件中,并根据您的测试框架相应地调整代码。下面两个示例都将
- 根据通配符模式导入所有故事文件
- 遍历这些文件,并在每个模块上使用
composeStories
,从而生成来自每个故事的可渲染组件列表
- 循环遍历故事,渲染它们并对其进行快照
如果使用 Vitest 作为测试框架,可以通过参考以下示例开始将快照测试迁移到 Storybook 的可移植故事,并使用 composeStories
帮助器 API。您需要修改 storybook.test.ts|js
文件中的代码,如下所示
// @vitest-environment jsdom
// Replace your-framework with one of the supported Storybook frameworks (react, vue3)
import type { Meta, StoryFn } from '@storybook/your-framework';
import { describe, expect, test } from 'vitest';
// Replace your-renderer with the renderer you are using (e.g., react, vue3, svelte, etc.)
import { composeStories } from '@storybook/your-renderer';
type StoryFile = {
default: Meta;
[name: string]: StoryFn | Meta;
};
const compose = (entry: StoryFile): ReturnType<typeof composeStories<StoryFile>> => {
try {
return composeStories(entry);
} catch (e) {
throw new Error(
`There was an issue composing stories for the module: ${JSON.stringify(entry)}, ${e}`
);
}
};
function getAllStoryFiles() {
// Place the glob you want to match your story files
const storyFiles = Object.entries(
import.meta.glob<StoryFile>('./stories/**/*.(stories|story).@(js|jsx|mjs|ts|tsx)', {
eager: true,
})
);
return storyFiles.map(([filePath, storyFile]) => {
const storyDir = path.dirname(filePath);
const componentName = path.basename(filePath).replace(/\.(stories|story)\.[^/.]+$/, '');
return { filePath, storyFile, componentName, storyDir };
});
}
// Recreate similar options to Storyshots. Place your configuration below
const options = {
suite: 'Storybook Tests',
storyKindRegex: /^.*?DontTest$/,
storyNameRegex: /UNSET/,
snapshotsDirName: '__snapshots__',
snapshotExtension: '.storyshot',
};
describe(options.suite, () => {
getAllStoryFiles().forEach(({ storyFile, componentName, storyDir }) => {
const meta = storyFile.default;
const title = meta.title || componentName;
if (options.storyKindRegex.test(title) || meta.parameters?.storyshots?.disable) {
// Skip component tests if they are disabled
return;
}
describe(title, () => {
const stories = Object.entries(compose(storyFile))
.map(([name, story]) => ({ name, story }))
.filter(({ name, story }) => {
// Implements a filtering mechanism to avoid running stories that are disabled via parameters or that match a specific regex mirroring the default behavior of Storyshots.
return !options.storyNameRegex?.test(name) && !story.parameters.storyshots?.disable;
});
if (stories.length <= 0) {
throw new Error(
`No stories found for this module: ${title}. Make sure there is at least one valid story for this module, without a disable parameter, or add parameters.storyshots.disable in the default export of this file.`
);
}
stories.forEach(({ name, story }) => {
// Instead of not running the test, you can create logic to skip it, flagging it accordingly in the test results.
const testFn = story.parameters.storyshots?.skip ? test.skip : test;
testFn(name, async () => {
await story.run();
// Ensures a consistent snapshot by waiting for the component to render by adding a delay of 1 ms before taking the snapshot.
await new Promise((resolve) => setTimeout(resolve, 1));
expect(document.body.firstChild).toMatchSnapshot();
});
});
});
});
});
当使用 Vitest 执行测试时,它将生成一个包含项目中所有故事的单个快照文件(即,storybook.test.ts|js.snap
)。但是,如果要生成单个快照文件,可以使用 Vitest 的 toMatchFileSnapshot
API。例如
// ...Code omitted for brevity
describe(options.suite, () => {
// 👇 Add storyDir in the arguments list
getAllStoryFiles().forEach(({ filePath, storyFile, storyDir }) => {
// ...Previously existing code
describe(title, () => {
// ...Previously existing code
stories.forEach(({ name, story }) => {
// ...Previously existing code
testFn(name, async () => {
await story.run();
// Ensures a consistent snapshot by waiting for the component to render by adding a delay of 1 ms before taking the snapshot.
await new Promise((resolve) => setTimeout(resolve, 1));
// 👇 Define the path to save the snapshot to:
const snapshotPath = path.join(
storyDir,
options.snapshotsDirName,
`${componentName}${options.snapshotExtension}`
);
expect(document.body.firstChild).toMatchFileSnapshot(snapshotPath);
});
});
});
});
});
如果使用 Jest 作为测试框架,可以通过参考以下示例开始将快照测试迁移到 Storybook 的可移植故事,并使用 composeStories
帮助器 API。您需要修改 storybook.test.ts|js
文件中的代码,如下所示
import path from 'path';
import * as glob from 'glob';
// Replace your-framework with one of the supported Storybook frameworks (react, vue3)
import type { Meta, StoryFn } from '@storybook/your-framework';
import { describe, test, expect } from '@jest/globals';
// Replace your-renderer with the renderer you are using (e.g., react, vue3, svelte, etc.)
import { composeStories } from '@storybook/your-renderer';
type StoryFile = {
default: Meta;
[name: string]: StoryFn | Meta;
};
const compose = (entry: StoryFile): ReturnType<typeof composeStories<StoryFile>> => {
try {
return composeStories(entry);
} catch (e) {
throw new Error(
`There was an issue composing stories for the module: ${JSON.stringify(entry)}, ${e}`
);
}
};
function getAllStoryFiles() {
// Place the glob you want to match your stories files
const storyFiles = glob.sync(
path.join(__dirname, 'stories/**/*.{stories,story}.{js,jsx,mjs,ts,tsx}')
);
return storyFiles.map((filePath) => {
const storyFile = require(filePath);
return { filePath, storyFile };
});
}
// Recreate similar options to Storyshots. Place your configuration below
const options = {
suite: 'Storybook Tests',
storyKindRegex: /^.*?DontTest$/,
storyNameRegex: /UNSET/,
snapshotsDirName: '__snapshots__',
snapshotExtension: '.storyshot',
};
describe(options.suite, () => {
getAllStoryFiles().forEach(({ storyFile, componentName }) => {
const meta = storyFile.default;
const title = meta.title || componentName;
if (options.storyKindRegex.test(title) || meta.parameters?.storyshots?.disable) {
// Skip component tests if they are disabled
return;
}
describe(title, () => {
const stories = Object.entries(compose(storyFile))
.map(([name, story]) => ({ name, story }))
.filter(({ name, story }) => {
// Implements a filtering mechanism to avoid running stories that are disabled via parameters or that match a specific regex mirroring the default behavior of Storyshots.
return !options.storyNameRegex.test(name) && !story.parameters.storyshots?.disable;
});
if (stories.length <= 0) {
throw new Error(
`No stories found for this module: ${title}. Make sure there is at least one valid story for this module, without a disable parameter, or add parameters.storyshots.disable in the default export of this file.`
);
}
stories.forEach(({ name, story }) => {
// Instead of not running the test, you can create logic to skip it, flagging it accordingly in the test results.
const testFn = story.parameters.storyshots?.skip ? test.skip : test;
testFn(name, async () => {
await story.run();
// Ensures a consistent snapshot by waiting for the component to render by adding a delay of 1 ms before taking the snapshot.
await new Promise((resolve) => setTimeout(resolve, 1));
expect(document.body.firstChild).toMatchSnapshot();
});
});
});
});
});
当使用 Jest 执行测试时,它将生成一个包含项目中所有故事的单个快照文件(即,__snapshots__/storybook.test.ts|js.snap
)。但是,如果要生成单个快照文件,可以使用 jest-specific-snapshot
包。例如
// 👇 Augment expect with jest-specific-snapshot
import 'jest-specific-snapshot';
// ...Code omitted for brevity
describe(options.suite, () => {
//👇 Add storyDir in the arguments list
getAllStoryFiles().forEach(({ filePath, storyFile, storyDir }) => {
// ...Previously existing code
describe(title, () => {
// ...Previously existing code
stories.forEach(({ name, story }) => {
// ...Previously existing code
testFn(name, async () => {
await story.run();
// Ensures a consistent snapshot by waiting for the component to render by adding a delay of 1 ms before taking the snapshot.
await new Promise((resolve) => setTimeout(resolve, 1));
//👇 Define the path to save the snapshot to:
const snapshotPath = path.join(
storyDir,
options.snapshotsDirName,
`${componentName}${options.snapshotExtension}`
);
expect(document.body.firstChild).toMatchSpecificSnapshot(snapshotPath);
});
});
});
});
});
如果选择在测试中使用可移植故事,将拥有一个可以在 JSDOM 环境中运行的单个测试文件,渲染并快照所有故事。但是,随着项目规模的增长,可能会遇到以前在 Storyshots 中遇到的限制
- 您没有针对真实浏览器进行测试。
- 必须模拟许多浏览器实用程序(例如,canvas、窗口 API 等)。
- 由于无法在测试中访问浏览器,因此调试体验不会那么好。
或者,可以考虑迁移到 Storybook 可用的其他快照测试选项:测试运行器,它提供更强大的解决方案,可以使用 Playwright 在真实浏览器环境中运行测试。
由于使用 Storybook 和测试运行器运行快照测试可能会导致一些技术限制,从而阻止您成功设置或运行测试,因此我们准备了一组说明来帮助您解决可能遇到的任何问题。
如果在使用测试运行器时遇到间歇性测试失败,则在测试在浏览器中运行时可能会发生未捕获的错误。如果以前使用的是 Storyshots 附加组件,则可能不会捕获这些错误。默认情况下,测试运行器会将这些未捕获的错误视为失败的测试。但是,如果预期这些错误,则可以通过在故事和测试运行器配置文件中启用自定义故事标签来忽略它们。有关更多信息,请参阅 测试运行器文档。
如果已配置测试运行器以运行快照测试,可能会注意到快照文件的路径和名称与 Storyshots 附加组件以前生成的路径和名称不同。这是因为测试运行器使用不同的快照文件命名约定。使用自定义快照解析器,可以配置测试运行器以使用以前使用的相同命名约定。
运行以下命令以生成测试运行器的自定义配置文件,可以用来配置 Jest
npm run test-storybook -- --eject
更新文件并启用 snapshotResolver
选项以使用自定义快照解析器
./test-runner-jest.config.js
import { getJestConfig } from '@storybook/test-runner';
const defaultConfig = getJestConfig();
const config = {
// The default Jest configuration comes from @storybook/test-runner
...defaultConfig,
snapshotResolver: './snapshot-resolver.js',
};
export default config;
最后,创建一个 snapshot-resolver.js
文件来实现自定义快照解析器
import path from 'path';
export default {
resolveSnapshotPath: (testPath) => {
const fileName = path.basename(testPath);
const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '');
const modifiedFileName = `${fileNameWithoutExtension}.storyshot`;
// Configure Jest to generate snapshot files using the following naming convention (__snapshots__/Button.storyshot)
return path.join(path.dirname(testPath), '__snapshots__', modifiedFileName);
},
resolveTestPath: (snapshotFilePath, snapshotExtension) =>
path.basename(snapshotFilePath, snapshotExtension),
testPathForConsistencyCheck: 'example.storyshot',
};
默认情况下,测试运行器使用 jest-serializer-html
来序列化 HTML 快照。这可能会导致格式与您现有的快照不同,即使您使用特定 CSS-in-JS 库,例如 Emotion、Angular 的 ng
属性或其他类似库来生成基于哈希的 CSS 类标识符。但是,您可以通过使用自定义快照序列化器来解决此问题,方法是将随机类名覆盖为在每次测试运行中都相同的静态类名,从而配置测试运行器。
运行以下命令以生成测试运行器的自定义配置文件,您可以使用它来提供其他配置选项。
npm run test-storybook -- --eject
更新文件并启用 snapshotSerializers
选项以使用自定义快照解析器。例如
./test-runner-jest.config.js
import { getJestConfig } from '@storybook/test-runner';
const defaultConfig = getJestConfig();
const config = {
...defaultConfig,
snapshotSerializers: [
// Sets up the custom serializer to preprocess the HTML before it's passed onto the test-runner
'./snapshot-serializer.js',
...defaultConfig.snapshotSerializers,
],
};
export default config;
最后,创建一个 snapshot-serializer.js
文件来实现自定义快照序列化器
// The jest-serializer-html package is available as a dependency of the test-runner
const jestSerializerHtml = require('jest-serializer-html');
const DYNAMIC_ID_PATTERN = /"react-aria-\d+(\.\d+)?"/g;
module.exports = {
/*
* The test-runner calls the serialize function when the test reaches the expect(SomeHTMLElement).toMatchSnapshot().
* It will replace all dynamic IDs with a static ID so that the snapshot is consistent.
* For instance, from <label id="react-aria970235672-:rl:" for="react-aria970235672-:rk:">Favorite color</label> to <label id="react-mocked_id" for="react-mocked_id">Favorite color</label>
*/
serialize(val) {
const withFixedIds = val.replace(DYNAMIC_ID_PATTERN, 'mocked_id');
return jestSerializerHtml.print(withFixedIds);
},
test(val) {
return jestSerializerHtml.test(val);
},
};
当测试运行器执行您的测试时,它将内省生成的 HTML 并将所有动态生成的属性替换为正则表达式提供的静态属性,然后再对组件进行快照。