文件
Storybook 文件

快照測試

快照測試會將每個 story 的呈現標記與已知的基準進行比較。這是一種識別觸發呈現錯誤和警告的標記變更的方式。

Storybook 是一個有用的快照測試工具,因為每個 story 本質上都是一個測試規格。每當您撰寫或更新 story 時,您都會免費獲得快照測試。

Example Snapshot test

如果您要升級到 Storybook 8.0,並且使用 Storyshots 附加元件進行快照測試,它已在此版本中正式棄用並移除。請參閱移轉指南以取得更多資訊。

使用測試執行器自動執行快照測試

Storybook 測試執行器會將您的所有 stories 轉換為可執行的測試。由 JestPlaywright 提供支援。它是一個獨立的、與框架無關的公用程式,與您的 Storybook 並行執行。它使您能夠在多瀏覽器環境中執行多種測試模式,包括使用play 函式進行元件測試、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 hook 允許您擴充測試執行器的預設設定。請在這裡閱讀更多關於它們的資訊:這裡

當您執行測試執行器時 (例如,使用 yarn test-storybook),它將執行您的所有 stories 並執行快照測試,在 __snapshots__ 目錄中為專案中的每個 story 產生一個快照檔案。

設定

測試執行器開箱即用,提供內建的快照測試設定,涵蓋大多數使用案例。您也可以透過 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;

執行測試執行器時,它將循環執行您的所有 stories 並執行快照測試,在您指定的自訂目錄中為專案中的每個 story 產生一個快照檔案。

自訂快照序列化

預設情況下,測試執行器會使用 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,在建立元件快照之前,將動態產生的屬性替換為自訂序列化器檔案中正規表示式提供的靜態屬性。這可確保快照在不同的測試執行中保持一致。

使用可攜式 Stories 進行快照測試

Storybook 提供了一個 composeStories 工具函式,可協助將測試檔案中的 stories 轉換為可在您的 Node 測試中使用 JSDOM 重複使用的可渲染元素。它還允許您將已在專案中啟用的其他 Storybook 功能 (例如,裝飾器args) 應用於您的測試中,使您可以在您選擇的測試環境 (例如,JestVitest) 中重複使用您的 stories,確保您的測試始終與您的 stories 同步,而無需重寫它們。這就是我們在 Storybook 中所稱的可攜式 stories。

必須設定您的測試環境以使用可攜式 stories,以確保您的 stories 會與您的 Storybook 設定的所有方面 (例如 裝飾器) 一起組合。

對單個 story 執行測試

如果您需要在單個 story 上執行測試,您可以使用適當框架中的 composeStories 函式來處理它,並套用您在 stories 中定義的任何設定 (例如,裝飾器args),並將其與您的測試環境結合以產生快照檔案。例如,如果您正在處理一個元件,並且想要測試其預設狀態,確保預期的 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();
});

對多個 stories 執行測試

您也可以使用 composeStories 函式來測試多個 stories。當您想要擴展測試覆蓋範圍以產生專案中不同元件狀態的快照時,這非常有用。為此,您可以如下編寫測試

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();
        });
      });
    });
  });
});

當您的測試在您的測試環境中執行時,它們將產生一個包含您專案中所有 stories 的單個快照檔案 (即,storybook.test.ts|js.snap)。但是,如果您需要,您可以使用 Vitest 的 toMatchFileSnapshot API 或 Jest 的 jest-specific-snapshot 套件來擴展您的測試檔案,以便為您專案中的每個 story 產生個別的快照檔案。例如

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);
      });
    });
  });
});

快照測試和視覺測試有什麼區別?

視覺測試會擷取 stories 的影像,並將它們與影像基準進行比較。快照測試會拍攝 DOM 快照並將它們與 DOM 基準進行比較。視覺測試更適合用於驗證外觀。快照測試適用於冒煙測試並確保 DOM 不會變更。

了解其他 UI 測試