Storyshots 非推奨になったので Storyshots 相当を自作する
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 でカスタムシリアライザを使用していた場合に対応する移行戦略を書きました。
この作業をした実コードを詳しく追いたい人はこちらを見てください。
TL;DR
- with-portable-stories を利用
- スナップショットを個別に生成するため、jest-specific-snapshot を利用
マイグレーションガイドに従って移行
Storybook の test-runner は、ヘッドレスブラウザを通じて Storybook を読み込み、HTML 形式で出力を得ることができます。しかし、snapshotSerializers
を使用している場合、この変更はすぐに適応できないこともあります。
例えば、jest-styled-components
[1] は、スタイル付きコンポーネントを使用している場合、スナップショットに CSS を含めてくれるシリアライザです。
jest-styled-components
は、FiberNode や HTMLElement を引数として期待しているため、react-test-renderer
や @testing-library/react
を使用してストーリーをレンダリングする必要があります。
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__
を生成します。
jest-specific-snapshot を利用する
toMatchSpecificSnapshot
を expect から生やすために必ず @types/jest-specific-snapshot
をインストールしてください。
npm i -D jest-specific-snapshot @types/jest-specific-snapshot
少し 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 を呼んでくれません。
の通り 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-snapshot
の SnapshotState
を利用しているので自動で反映はしてくれないです。
ざっくり見ていくとこういう感じ。コード上で明確に addSerializer
が必要。
snapshotState.save()
の部分
実際に snapshot をファイルに保存するところ
serialize 関数を通して snapshot を生成しているところ
serialize 関数
getSerializers が返している PLUGINS を変更しているところ
できた。
おわりに
Storyshots が非推奨となり、移行が必要になったが、ほぼ Storyshots を自前で実装した形になりました。Linux 環境では、jest のスナップショットの拡張子を .snap にすると問題が発生するため、注意が必要です。
Discussion