文档
Storybook 文档

Storyshots 迁移指南

我们正在积极整合社区反馈,以改进 Storybook 的快照测试工具和文档。如果您有兴趣参与此过程并帮助我们改进它。请填写此 表格 分享您的反馈。

本指南将教你如何将快照测试从 Storyshots 附加组件迁移到 Storybook 的测试运行器或可移植故事。此外,您将能够了解它们之间的差异,并使用 Storybook 提供的可用工具设置、配置和运行快照测试。

从 Storyshots 迁移测试

先决条件

在开始迁移过程之前,请确保您拥有

  • 一个使用最新稳定版本(例如,7.6 或更高版本)运行的 受支持框架 配置的完整功能 Storybook。
  • 熟悉您当前的 Storybook 及其测试设置。

使用测试运行器

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

设置

要从 Storyshots 附加组件开始迁移到测试运行器,建议您从项目中删除 Storyshots 附加组件和类似的包(例如,storybook/addon-storyshots-puppeteer ),包括任何相关的配置文件。然后,按照测试运行器的 设置说明 进行安装、配置和运行。

扩展您的测试覆盖范围

Storyshots 附加组件提供了一种高度可定制的测试解决方案,允许用户以多种方式扩展测试覆盖范围。但是,测试运行器提供了类似的体验,但具有不同的 API。您将在下面找到使用测试运行器以类似于使用 Storyshots 时的方式实现结果的其他示例。

使用测试运行器启用 DOM 快照测试

要使用测试运行器启用 DOM 快照测试,您可以扩展测试运行器的配置文件并使用可用的 钩子 将它们与 Playwright 的内置 API 相结合,以生成项目中每个故事的 DOM 快照。例如

.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;

如果您使用测试运行器在项目中设置了 DOM 快照测试,并通过 CLI 标志启用了 index.json 模式,则测试将在项目外部的临时文件夹中生成,快照将与测试一起存储。您需要扩展测试运行器的配置并提供自定义快照解析器,以允许快照位于不同的位置。有关更多信息,请参阅 故障排除 部分。

使用测试运行器运行图像快照测试默认情况下,测试运行器提供运行多个测试模式(例如,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 导入项目级注释

如果需要在测试中启用项目级注释(例如,装饰器参数、样式)在您的 ./storybook/preview.js|ts 中,请调整您的测试设置文件以按如下方式导入注释。

setupTest.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,我们准备了一些示例来帮助您入门。下面列出了两个最流行的测试框架的示例:JestVitest。建议将代码放在新创建的 storybook.test.ts|js 文件中,并根据您的测试框架相应地调整代码。下面两个示例都将

  • 根据通配符模式导入所有故事文件
  • 遍历这些文件,并在每个模块上使用 composeStories,从而生成来自每个故事的可渲染组件列表
  • 循环遍历故事,渲染它们并对其进行快照

Vitest

如果使用 Vitest 作为测试框架,可以通过参考以下示例开始将快照测试迁移到 Storybook 的可移植故事,并使用 composeStories 帮助器 API。您需要修改 storybook.test.ts|js 文件中的代码,如下所示

storybook.test.ts
// @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。例如

storybook.test.js|ts
// ...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

如果使用 Jest 作为测试框架,可以通过参考以下示例开始将快照测试迁移到 Storybook 的可移植故事,并使用 composeStories 帮助器 API。您需要修改 storybook.test.ts|js 文件中的代码,如下所示

storybook.test.ts
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 包。例如

storybook.test.js|ts
// 👇 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 文件来实现自定义快照解析器

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

快照文件格式与 Storyshots 附加组件生成的格式不同

默认情况下,测试运行器使用 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 文件来实现自定义快照序列化器

./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 并将所有动态生成的属性替换为正则表达式提供的静态属性,然后再对组件进行快照。