📘

Storyshots 非推奨になったので Storyshots 相当を自作する

2024/02/09に公開

https://storybook.js.org/docs/writing-tests/snapshot-testing#migrating-tests

As a result, Storyhots is now officially deprecated, is no longer being maintained, and will be removed in the next major release of Storybook. We recommend following the migration guide we've prepared to help you during this transition period.

とあるように Storyshots が非推奨になりました。

移行手順と Storyshots と出力が変わらないように migration guide の内容を改造します。

はじめに

この記事では、Storyshots から Storybook の新しいテストランナーへの移行プロセスに焦点を当てています。特に、Storyshots でカスタムシリアライザを使用していた場合に対応する移行戦略を書きました。

この作業を行った実コードを詳しく追いたい人はこちらを見てください。

https://github.com/pixiv/charcoal/pull/458

TL;DR

マイグレーションガイドに従って移行

https://storybook.js.org/docs/writing-tests/storyshots-migration-guide

Storybook の test-runner は、ヘッドレスブラウザを通じて Storybook を読み込み、HTML 形式で出力を得ることができます。しかし、snapshotSerializers を使用している場合、この変更はすぐに適応できないかもしれません。

例えば、jest-styled-components[1] は、スタイル付きコンポーネントを使用している場合にスナップショットに CSS を含めてくれるシリアライザです。jest-styled-components は、FiberNode や HTMLElement を引数として期待しているため、react-test-renderer@testing-library/react を使用してストーリーをレンダリングする必要があります。

Portable Stories

https://storybook.js.org/docs/writing-tests/storyshots-migration-guide#with-portable-stories

ドキュメント通りにファイルと設定を行っていくのですが、storybook.test.ts だけ jest 環境下においてそのまま動かないので修正します。

Storybook はこのようなケースをサポートするために composeStories ユーティリティを提供しています。これは、ストーリーファイルからレンダリング可能な要素へストーリーを変換し、JSDOM で Node テストに再利用できるようにするものです。これにより、プロジェクトで有効になっている他の Storybook 機能(デコレーターや引数など)を適用し、コンポーネントが正しくレンダリングされるようにします。

しかし、storybook.test.ts をそのまま Jest 環境で動かそうとすると問題が発生します。node-glob を使用している場合、修正が必要です。[2]

   const storyFiles = glob.sync(
-    path.join(__dirname, 'stories/**/*.(stories|story).@(js|jsx|mjs|ts|tsx)'),
+    path.join(__dirname, 'stories/**/*.{stories,story}.{js,jsx,mjs,ts,tsx}'),
   );
完成形
import path from "path";
import * as glob from "glob";
import { describe, test, expect } from "@jest/globals";
import { render } from "@testing-library/react";

import { composeStories } from "@storybook/react";
import type { Meta, StoryFn } from "@storybook/react";

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 () => {
          const mounted = render(story());
          // 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(mounted.container).toMatchSnapshot();
        });
      });
    });
  });
});

これにより、各ストーリーが存在するディレクトリに __snapshots__ が生成され、すべてのスナップショットが保存されます。Storyshots は、各ストーリーごとに __snapshots__ を生成していました。次に、その対応を実施します。

各ストーリーのディレクトリにスナップショットを生成

storyshots で利用されていた jest-specific-snapshot を利用して各 story があるディレクトリに __snapshots__ を生成します。

https://github.com/igor-dv/jest-specific-snapshot

jest-specific-snapshot を利用する

toMatchSpecificSnapshot を expect から生やすために必ず @types/jest-specific-snapshot をインストールしてください。

npm i -D jest-specific-snapshot @types/jest-specific-snapshot

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/jest-image-snapshot/index.d.ts#L158-L164

少し storybook.test.ts を修正します。

import 群の最終行に import "jest-specific-snapshot"; を追加して toMatchSpecificSnapshot を利用できるようにします。

testFn(name, async () => {
  const mounted = render(story());
  // 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(mounted.container).toMatchSnapshot();
+  const dir = path.join(path.dirname(filePath), options.snapshotsDirName)
+  const filename = [path.basename(filePath, '.tsx'), options.snapshotExtension].join('.')
+  expect(mounted).toMatchSpecificSnapshot(snapshotPath)
});
完成形
import path from "path";
import * as glob from "glob";
import { describe, test, expect } from "@jest/globals";
import { render } from "@testing-library/react";

import { composeStories } from "@storybook/react";
import type { Meta, StoryFn } from "@storybook/react";

import "jest-specific-snapshot";

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 () => {
          const mounted = render(story());
          // 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));

          const dir = path.join(path.dirname(filePath), options.snapshotsDirName);
          const filename = [path.basename(filePath, ".tsx"), options.snapshotExtension].join(".");
          expect(mounted).toMatchSpecificSnapshot(snapshotPath);
        });
      });
    });
  });
});

jest-specific-snapshot で serializer を利用する

jest-specific-snapshot は jest.config に書いてある snapshotSerializers を呼んでくれません。

https://github.com/igor-dv/jest-specific-snapshot/blob/master/README.md#with-custom-serializer

の通り addSerializer を通して serializer を登録します。

// extend jest to have 'toMatchSpecificSnapshot' matcher
const addSerializer = require("jest-specific-snapshot").addSerializer;

addSerializer(/* Add custom serializer here */);

test("test", () => {
  expect(/* thing that matches the custom serializer */).toMatchSpecificSnapshot(
    "./specific/custom_serializer/test.shot",
  );
});
呼んでくれない理由

snapshot の生成で内部で jest-snapshotSnapshotState を利用しているので自動で反映はしてくれないです。
ざっくり見ていくとこういう感じ。コード上で明確に addSerializer が必要。

snapshotState.save() の部分

https://github.com/igor-dv/jest-specific-snapshot/blob/master/src/index.js#L21

実際に snapshot をファイルに保存するところ
https://github.com/jestjs/jest/blob/main/packages/jest-snapshot/src/State.ts#L159

serialize 関数を通して snapshot を生成しているところ
https://github.com/jestjs/jest/blob/main/packages/jest-snapshot/src/State.ts#L218-L220

serialize 関数
https://github.com/jestjs/jest/blob/main/packages/jest-snapshot/src/utils.ts#L179

getSerializers が返している PLUGINS を変更しているところ
https://github.com/jestjs/jest/blob/main/packages/jest-snapshot/src/plugins.ts#L35-L37

snapshot が個別にとれてる

できた。

おわりに

Storyshots が非推奨となり、移行が必要になったが、ほぼ Storyshots を自前で実装した形になりました。Linux 環境では、jest のスナップショットの拡張子を .snap にすると問題が発生するため、注意が必要です。

https://github.com/jestjs/jest/issues/8922

脚注
  1. jest-styled-components ↩︎

  2. 修正 PR ↩︎

Discussion