文件
Storybook 文件

Storyshots 遷移指南

我們正在積極整合社群回饋,以改進 Storybook 快照測試的工具和文件。如果您有興趣參與此過程並協助我們改進,請填寫此表單以分享您的回饋。

本指南將教您如何將快照測試從 Storyshots 外掛遷移到 Storybook 的測試執行器或可攜式 Stories。此外,您將能夠了解它們之間的差異,並使用 Storybook 提供的可用工具設定、配置和執行快照測試。

從 Storyshots 遷移測試

先決條件

在開始遷移過程之前,請確保您具備以下條件:

  • 一個完全正常運作的 Storybook,配置了支援的框架之一,並執行最新的穩定版本(即 7.6 或更高版本)。
  • 熟悉您目前的 Storybook 及其測試設定。

使用測試執行器

Storybook 測試執行器會將您的所有 stories 轉換為可執行的測試。由 JestPlaywright 提供支援。它是一個獨立、與框架無關的工具,與您的 Storybook 並行執行。它使您能夠在多瀏覽器環境中執行多種測試模式,包括使用play 函式進行元件測試、DOM 快照和無障礙測試

設定

若要開始從 Storyshots 外掛遷移到測試執行器的過程,我們建議您從專案中移除 Storyshots 外掛和類似的套件(例如 storybook/addon-storyshots-puppeteer),包括任何相關的設定檔。然後,按照測試執行器的設定說明來安裝、設定和執行它。

擴展您的測試覆蓋率

Storyshots 外掛提供了高度可自訂的測試解決方案,允許使用者以多種方式擴展測試覆蓋率。但是,測試執行器提供了類似的體驗,但具有不同的 API。在下方,您將找到使用測試執行器來達成與您使用 Storyshots 達成之結果相似的其他範例。

使用測試執行器啟用 DOM 快照測試

若要使用測試執行器啟用 DOM 快照測試,您可以擴展測試執行器的設定檔,並使用可用的掛鉤,並將它們與 Playwright 的內建 API 結合使用,以產生專案中每個 Story 的 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;

使用可攜式 Stories

Storybook 提供了一個 composeStories 公用程式,可協助將 story 檔案中的 stories 轉換為可呈現的元素,這些元素可以在您的 Node 測試中與 JSDOM 重複使用。它還允許您套用在專案中啟用的其他 Storybook 功能(例如,裝飾器參數),這可讓您的元件正確呈現。這就是所謂的可攜式 stories。

設定

我們建議您關閉目前的 storyshots 測試,以開始遷移過程。為此,請將設定檔(即 storybook.test.ts|js 或類似檔案)重新命名為 storybook.test.ts|js.old。這會防止偵測到測試,因為您將建立一個具有相同名稱的新測試設定檔。這樣做,您將能夠在遷移到可攜式 stories 之前保留現有的測試,然後再從專案中移除 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);

為可攜式 stories 設定測試框架

為了協助您從 Storyshots 附加元件遷移到 Storybook 的可攜式 stories,並使用 composeStories 輔助 API,我們準備了一些範例來協助您入門。下面列出的是兩個最受歡迎的測試框架的範例:JestVitest。我們建議將程式碼放置在新建的 storybook.test.ts|js 檔案中,並根據您的測試框架調整程式碼。以下兩個範例都會

  • 根據 glob 模式匯入所有 story 檔案
  • 反覆查看這些檔案,並在其每個模組上使用 composeStories,產生每個 story 的可呈現元件清單
  • 循環瀏覽 stories、呈現它們並建立快照

Vitest

如果您使用 Vitest 作為測試框架,您可以開始將快照測試遷移到 Storybook 的可攜式 stories,並使用 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 執行測試時,它會產生一個包含專案中所有 stories 的單一快照檔案(即 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 的可攜式 stories,並使用 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 執行測試時,它會產生一個包含專案中所有 stories 的單一快照檔案(即 __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);
        });
      });
    });
  });
});

已知限制

如果您選擇在測試中使用可攜式 stories,您將會有一個可以在 JSDOM 環境中執行的單一測試檔案,其中會呈現您所有的 stories 並建立快照。但是,隨著專案的成長,您可能會遇到之前使用 Storyshots 時遇到的限制

  • 您不是針對真實的瀏覽器進行測試。
  • 您必須模擬許多瀏覽器公用程式(例如,canvas、window API 等)。
  • 您的除錯體驗不會那麼好,因為您無法在測試中存取瀏覽器。

或者,您可能會想考慮遷移到 Storybook 快照測試的其他可用選項:測試執行器,它是一種更健全的解決方案,可在真實的瀏覽器環境中使用 Playwright 執行測試。


疑難排解

由於使用 Storybook 和測試執行器執行快照測試可能會導致一些技術限制,而這些限制可能會阻止您成功設定或執行測試,因此我們準備了一組說明,以協助您排解可能遇到的任何問題。

測試執行器在執行快照測試時報告錯誤

如果您在使用測試執行器時遇到間歇性的測試失敗,則當您的測試在瀏覽器中執行時,可能會發生未捕獲的錯誤。如果您之前使用的是 Storyshots 附加元件,可能不會捕獲這些錯誤。預設情況下,測試執行器會將這些未捕獲的錯誤視為失敗的測試。但是,如果這些錯誤是預期的,您可以在您的 stories 和測試執行器設定檔中啟用自訂 story 標籤來忽略它們。如需更多資訊,請參閱 測試執行器文件

測試執行器未在預期的目錄中產生快照檔案

如果您已將測試執行器設定為執行快照測試,您可能會注意到快照檔案的路徑和名稱與 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,並在建立元件快照之前,使用正規表示式提供的靜態屬性來取代任何動態產生的屬性。