文档
Storybook 文档

快照测试

快照测试会将每个故事的渲染标记与已知的基线进行比较。这是一种识别触发渲染错误和警告的标记更改的方法。

Storybook 是一个有助于进行快照测试的工具,因为每个故事本质上都是一个测试规范。每次编写或更新故事时,您都会免费获得一个快照测试。

Example Snapshot test

如果您正在升级到 Storybook 8.0 并且正在使用 Storyshots 插件进行快照测试,则该插件已在本次发布中正式弃用并移除。请参阅迁移指南以获取更多信息。

使用测试运行器自动执行快照测试

Storybook 测试运行器将您的所有故事都转换为可执行的测试。由JestPlaywright提供支持。它是一个独立的、与框架无关的实用程序,与您的 Storybook 并行运行。它使您能够在多浏览器环境中运行多种测试模式,包括使用播放函数进行组件测试、DOM 快照和无障碍测试

设置

要使用测试运行器启用快照测试,您需要采取其他步骤来正确设置它。我们建议您在继续执行其余必要的配置之前,先阅读测试运行器文档,以了解有关可用选项和 API 的更多信息。

在您的 Storybook 目录中添加一个新的配置文件,其中包含以下内容

.storybook/test-runner.ts
import type { TestRunnerConfig } from '@storybook/test-runner';
 
const config: TestRunnerConfig = {
  async postVisit(page, context) {
    // the #storybook-root element wraps the story. In Storybook 6.x, the selector is #root
    const elementHandler = await page.$('#storybook-root');
    const innerHTML = await elementHandler.innerHTML();
    expect(innerHTML).toMatchSnapshot();
  },
};
 
export default config;

postVisit钩子允许您扩展测试运行器的默认配置。在此处详细了解它们此处

当您执行测试运行器(例如,使用yarn test-storybook)时,它将遍历所有故事并运行快照测试,为项目中位于__snapshots__目录中的每个故事生成一个快照文件。

配置

默认情况下,测试运行器提供了一个内置的快照测试配置,涵盖了大多数用例。您还可以通过test-storybook --eject或通过在项目根目录中创建test-runner-jest.config.js文件来微调配置以满足您的需求。

覆盖默认快照目录

测试运行器默认使用特定的命名约定和路径来生成快照文件。如果您需要自定义快照目录,您可以定义一个自定义快照解析器来指定存储快照的目录。

创建一个snapshot-resolver.js文件以实现自定义快照解析器

./snapshot-resolver.js
import path from 'path';
 
export default {
  resolveSnapshotPath: (testPath) => {
    const fileName = path.basename(testPath);
    const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '');
    // Defines the file extension for the snapshot file
    const modifiedFileName = `${fileNameWithoutExtension}.snap`;
 
    // Configure Jest to generate snapshot files using the following convention (./src/test/__snapshots__/Button.stories.snap)
    return path.join('./src/test/__snapshots__', modifiedFileName);
  },
  resolveTestPath: (snapshotFilePath, snapshotExtension) =>
    path.basename(snapshotFilePath, snapshotExtension),
  testPathForConsistencyCheck: 'example',
};

更新test-runner-jest.config.js文件并启用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;

当执行测试运行器时,它将循环遍历所有故事并运行快照测试,为项目中位于您指定的自定义目录中的每个故事生成一个快照文件。

自定义快照序列化

默认情况下,测试运行器使用jest-serializer-html来序列化 HTML 快照。如果您使用特定的 CSS-in-JS 库(如Emotion、Angular 的ng属性或类似库生成用于 CSS 类基于哈希的标识符),这可能会导致问题。如果您需要自定义快照的序列化,您可以定义一个自定义快照序列化器来指定快照的序列化方式。

创建一个snapshot-serializer.js文件以实现自定义快照序列化器

./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);
  },
};

更新 test-runner-jest.config.js 文件并启用 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;

当测试运行器执行您的测试时,它会内省生成的 HTML,使用自定义序列化器文件中的正则表达式将动态生成的属性替换为静态属性,然后再对组件进行快照。这确保了快照在不同的测试运行中保持一致。

使用 Portable Stories 进行快照测试

Storybook 提供了一个 composeStories 工具函数,它有助于将故事从测试文件转换为可渲染的元素,这些元素可以在您的 Node 测试中与 JSDOM 一起重用。它还允许您将已在项目中启用的其他 Storybook 功能(例如,装饰器参数)应用到您的测试中,使您能够在您选择的测试环境(例如,JestVitest)中重用您的故事,确保您的测试始终与您的故事保持同步,而无需重写它们。这就是我们在 Storybook 中所说的可移植故事。

您**必须**配置您的测试环境以使用可移植故事,以确保您的故事包含 Storybook 配置的所有方面,例如装饰器

在单个故事上运行测试

如果您需要在单个故事上运行测试,您可以使用相应框架中的 composeStories 函数来处理它并应用您在故事中定义的任何配置(例如,装饰器参数),并将其与您的测试环境结合以生成快照文件。例如,如果您正在处理一个组件并且想要测试其默认状态,确保预期的 DOM 结构没有改变,以下是如何编写您的测试。

test/Button.test.js|ts
import { composeStories } from '@storybook/react';
 
import * as stories from '../stories/Button.stories';
 
const { Primary } = composeStories(stories);
test('Button snapshot', async () => {
  await Primary.run();
  expect(document.body.firstChild).toMatchSnapshot();
});

在多个故事上执行测试

您还可以使用 composeStories 函数测试多个故事。当您希望扩展测试覆盖范围以生成项目中组件的不同状态的快照时,这很有用。为此,您可以按如下方式编写测试。

storybook.test.ts
// Replace your-framework with one of the supported Storybook frameworks (react, vue3)
import type { Meta, StoryFn } from '@storybook/your-framework';
 
import path from 'path';
import * as glob from 'glob';
 
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);
    const storyDir = path.dirname(filePath);
    const componentName = path.basename(filePath).replace(/\.(stories|story)\.[^/.]+$/, '');
 
    return { filePath, storyFile, storyDir, componentName };
  });
}
 
describe('Stories Snapshots', () => {
  getAllStoryFiles().forEach(({ storyFile, componentName }) => {
    const meta = storyFile.default;
    const title = meta.title || componentName;
 
    describe(title, () => {
      const stories = Object.entries(compose(storyFile)).map(([name, story]) => ({ name, story }));
 
      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.`
        );
      }
 
      stories.forEach(({ name, story }) => {
        test(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();
        });
      });
    });
  });
});

当您的测试在测试环境中执行时,它们将生成一个包含项目中所有故事的单个快照文件(即 storybook.test.ts|js.snap)。但是,如果需要,您可以扩展测试文件以使用 Vitest 的 toMatchFileSnapshot API 或 Jest 的 jest-specific-snapshot 包为项目中的每个故事生成单独的快照文件。例如:

storybook.test.ts
// Replace your-framework with one of the supported Storybook frameworks (react, vue3)
import type { Meta, StoryFn } from '@storybook/your-framework';
 
import path from "path";
import * as glob from "glob";
 
//👇 Augment expect with jest-specific-snapshot
import "jest-specific-snapshot";
 
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);
    const storyDir = path.dirname(filePath);
    const componentName = path
      .basename(filePath)
      .replace(/\.(stories|story)\.[^/.]+$/, "");
 
    return { filePath, storyFile, storyDir, componentName };
  });
}
 
describe("Stories Snapshots", () => {
  getAllStoryFiles().forEach(({ storyFile, componentName }) => {
    const meta = storyFile.default;
    const title = meta.title || componentName;
 
    describe(title, () => {
      const stories = Object.entries(compose(storyFile)).map(
        ([name, story]) => ({ name, story })
      );
 
      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.`
        );
      }
 
      stories.forEach(({ name, story }) => {
        test(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));
          // Defines the custom snapshot path location and file name
          const customSnapshotPath = `./__snapshots__/${componentName}.test.ts.snap`;
          expect(document.body.firstChild).toMatchSpecificSnapshot(customSnapshotPath);
      });
    });
  });
});

快照测试和视觉测试有什么区别?

视觉测试捕获故事的图像并将其与图像基线进行比较。快照测试获取 DOM 快照并将其与 DOM 基线进行比较。视觉测试更适合验证外观。快照测试可用于冒烟测试并确保 DOM 不发生更改。

了解其他 UI 测试