Storybookの見た目確認と、Playwrightのテストコードが別物だった話
フロントエンドのコンポーネントを作ると、まずStorybookでストーリーを書いて見た目を確認しますよね。ボタンのバリエーション、フォームのエラー状態、モーダルの開閉。ここまでは気持ちよく進みます。
ところが、いざ「このコンポーネントが実際に動くこと」をテストしようとすると、Storybookのストーリーとは別に、Playwright Component Testing(以下Playwright CT)のテストコードをもう一度書き直すことになります。同じargsを、同じdecoratorsを、もう一度手で入力し直す作業です。私も1年目の若いエンジニアから「Storybookで書いたものと、テストで書くものが二重管理になっていて辛い」と相談されたことがあります。まさにそのとおりだと思いました。
2026年、この二重管理を終わらせる仕組みがStorybook側に用意されました。composeStoriesというAPIです。今回はこれを実際に使いながら、なぜ今まで別物だったのか、composeStoriesで何が変わるのか、そしてそれでも使い分けが必要な場面はどこかを整理します。
なぜ今まで別物だったのか
StorybookのストーリーはもともとブラウザでUIを確認するために書かれたものです。一方、Vitestやjestといったテストランナーは、jsdomという「ブラウザっぽい環境をNode.js上に再現したもの」の上でコンポーネントをレンダリングしてテストを実行します。
jsdomは軽くて速い反面、本物のブラウザではありません。ResizeObserverはjsdomには実装されておらず、jsdomベースのテスト環境で使おうとするとReferenceErrorで落ちます。CanvasやWebGLも同様で、ブラウザAPIのおよそ2割がjsdomには存在しない、あるいは正しく動かないと言われています。
対してPlaywright CTは、本物のChromiumやFirefoxの中でコンポーネントを1つだけマウントしてテストします。レイアウト、フォント、ビューポート、そしてResizeObserverやCanvasも、本番と同じブラウザの挙動そのままです。
つまりStorybookのストーリーとjsdom系のテストは「同じReact/Vueのレンダラーを使っているのに、実行環境の性質がまったく違う」という状態でした。ストーリーをテストにそのまま持ち込めなかったのは、技術的な手抜きではなく、この前提の違いが理由だったわけです。
composeStoriesで何が起きるのか
ここで登場するのがStorybookのcomposeStoriesです。Storybook 8.1以降、ポータブルストーリー(Portable Stories)と呼ばれる仕組みがPlaywright CTにも対応しました。ストーリーファイルに書いたargs・decorators・parametersを、Playwright CTのテストの中でそのまま合成して使える形にしてくれます。
まずは元になるStorybookのストーリーです。特別なことは何もしていません。
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta = {
component: Button,
};
export default meta;
type Story = StoryObj;
export const Primary: Story = {
args: {
label: '送信する',
variant: 'primary',
},
};
export const Disabled: Story = {
args: {
label: '送信する',
variant: 'primary',
disabled: true,
},
};
これをPlaywright CTのテストから読み込みます。composeStoriesにストーリーファイルを渡すと、各ストーリーのargsやdecoratorsを反映した状態のコンポーネントを返してくれます。
// Button.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { composeStories } from '@storybook/react';
import * as stories from './Button.stories';
const { Primary, Disabled } = composeStories(stories);
test('Primaryストーリーが表示される', async ({ mount }) => {
const component = await mount( );
await expect(component).toContainText('送信する');
});
test('Disabledストーリーはクリックできない', async ({ mount }) => {
const component = await mount( );
await expect(component).toBeDisabled();
});
ここで書いているのは、ストーリーのargsを検証するだけの薄いテストではありません。PrimaryやDisabledという名前で呼び出しているのは、あくまでStorybook側で定義した状態です。テストコード側で改めてpropsを書き直す作業が丸ごとなくなっている、というのがポイントです。
これで実行してみると、Storybook上で見た目を確認したのと同じargs・同じdecoratorsのコンポーネントが、本物のブラウザの中でマウントされて動きます。「Storybookで見た通りのものが、そのままテストで動いた」と表示されたら成功です。ストーリーとテストが同じソースを見ているので、Storybookのargsを直しただけでテストが自然に追従してくれるようになります。
このcomposeStories自体はPlaywright CT専用ではありません。同じAPIでVitestやJestからもストーリーを読み込めます。1つのストーリーファイルが、Vitest・Jest・Playwright CTのどこからでも動く共通の資産になる、というのが今回の変更の核心です。
使ってみて分かった制約
ここまで書くと万能に見えますが、正直に制約も書いておきます。ポータブルストーリーがPlaywright CTで使えるのは、現時点ではReactとVue3のレンダラーに限られています。Angularやその他のフレームワークを使っている方は、まだこの恩恵を受けられません(今後広がっていくのかもしれませんが、そこは確約できることではないので執筆時点の事実として書いておきます)。
もう1つ、Playwright CTの環境構築自体は、composeStoriesがあっても省略できません。playwright-ct.config.tsの用意やコンポーネントのマウント設定は、以前Playwright + TypeScriptの環境構築を書いた記事と同様に、最初の1回はきちんと手を動かす必要があります。composeStoriesはあくまで「ストーリーとテストの二重管理」をなくす道具であって、Playwright CT自体のセットアップを肩代わりしてくれるわけではありません。
実行時に思ったより時間がかかる点も正直に書いておきます。Storybook側でdecoratorsに重い初期化処理を仕込んでいると、composeStoriesはそれをそのままPlaywright CTのテストにも持ち込みます。ストーリー側が軽量に保たれているかどうかが、そのままテストの速さに直結する感触でした。
それでもComponent Testingでないと検証できないもの
composeStoriesでストーリーとテストを一本化できても、「じゃあ全部Vitest上のjsdomでいいのでは」とはなりません。ここは使い分けの軸としてはっきりさせておきたいところです。
ResizeObserverを使ってレイアウトを切り替えるコンポーネント、CanvasでグラフやQRコードを描画するコンポーネント、WebGLを使った表示。こうしたブラウザAPIに強く依存するコンポーネントは、composeStories経由であってもjsdom上(Vitest・Jest)では正しく検証できません。ResizeObserverはjsdomに存在しないため、これに依存する分岐やイベントはそもそも発火しないからです。
逆に言えば、判断軸はシンプルです。
- props・状態・イベントハンドラの組み合わせだけで検証できるコンポーネント → composeStories経由でVitest/Jestに任せて、速く回す
- ResizeObserver・Canvas・WebGLなど本物のブラウザAPIに依存するコンポーネント → composeStories経由でPlaywright CTに任せて、正しさを取る
同じcomposeStories(stories)という書き方で両方に対応できるので、コンポーネントの性質に応じてテストランナーだけを使い分ける、という選び方ができるようになったのは大きいと感じました。「テストコードの書き方」ではなく「どの実行環境で動かすか」だけを切り替えればよくなったからです。
CIに組み込むとき、落ちたときの追い方
Playwright CTのテストも、通常のPlaywrightのテストと同じくCIに組み込めます。以前GitHub ActionsでPlaywrightを自動実行する記事を書きましたが、component testもテストコマンドを差し替えるだけで同じワークフローに乗ります。
composeStories経由のテストがCIで落ちたときは、どのストーリーのargsで失敗したのかが分かりにくいのでは、と最初は身構えていました。実際にはPrimaryやDisabledといったストーリー名がそのままテスト名に出てくるので、意外と迷いません。それでも「なぜこのargsだと崩れるのか」まで追いたいときは、以前紹介したtrace CLIの話と同じ要領で、Playwright CTのtraceを見れば、コンポーネント単体がどう描画されたかを後から確認できます。
まとめ
composeStoriesがPlaywright CTに対応したことで、「Storybookで見た目を確認する話」と「Playwrightでテストを書く話」を別々のコードで二重管理する必要がなくなりました。ストーリーに書いたargsやdecoratorsが、そのままテストの入力になります。
ただし、ReactとVue3限定であることや、Playwright CT自体の環境構築が要ることは変わりません。そして「ブラウザAPIに依存するコンポーネントはPlaywright CTでないと正しく検証できない」という判断軸そのものも、composeStoriesが登場した後も変わらず大事な考え方です。ストーリーとテストの管理は一本化しつつ、実行環境(jsdomか本物のブラウザか)はコンポーネントの中身で選ぶ。この2段構えが、今のところいちばんしっくりくる使い分けだと感じています。
まだStorybookとPlaywright CTのテストを別々に書いている方は、手元の1コンポーネントだけでもcomposeStoriesに置き換えてみることをおすすめします。ストーリーを直しただけでテストが追従してくる感覚は、体験してみるとなかなか気持ちいいものです。

コメント