ビルドシステム、テスト、そして開発ワークフロー
前提知識
- ›第1回: アーキテクチャ概要
- ›ビルドツールへの理解 (esbuild、Vite、webpack の概念)
- ›モノレポツールへの理解 (Nx、Yarn workspaces)
ビルドシステム、テスト、そして開発ワークフロー
これまでの5回にわたって、Storybook のランタイムアーキテクチャを掘り下げてきました — 3つの実行環境、プリセットシステム、チャンネル、Preview のレンダリングパイプライン、そして Manager UI です。しかし、いくら優れたコードを書いても、洗練されたビルドと開発インフラなしには本番環境へ届きません。Storybook のモノレポには 50 以上のパッケージが含まれており、依存関係の順序に従ってコンパイルし、サポートするすべてのフレームワーク組み合わせでテストし、一貫したエラーハンドリングとともに出荷する必要があります。
この最終回では、それを支える仕組みを詳しく見ていきます。具体的には、Nx によるタスクオーケストレーション、第1回で紹介した build-config.ts のエントリ分類、サイドバーを駆動する StoryIndexGenerator があります。さらに、スマートリビルドのための ChangeDetectionService、インテグレーションテスト用のサンドボックスシステム、構造化エラーシステムも取り上げます。
Nx によるタスクオーケストレーション
Storybook のモノレポでは、タスクオーケストレーションに Nx を使用しています。nx.json ファイルでタスクグラフを定義しています。
flowchart TD
Compile["compile"] -->|"^compile (dependencies first)"| Check["check"]
Compile --> Publish["publish"]
Publish --> RunRegistry["run-registry (verdaccio)"]
RunRegistry --> Sandbox["sandbox"]
Sandbox --> Build["build"]
Sandbox --> Dev["dev"]
Build --> Serve["serve"]
Serve --> E2E["e2e-tests"]
Serve --> TestRunner["test-runner"]
Build --> Chromatic["chromatic"]
Compile --> Jest["jest"]
Compile --> Vitest["vitest"]
Compile --> PlaywrightCT["playwright-ct"]
主要なタスクをまとめると次のとおりです:
| タスク | 依存先 | 目的 |
|---|---|---|
compile |
^compile (パッケージの依存関係) |
esbuild で単一パッケージをビルド |
check |
全パッケージのコンパイル完了 | TypeScript の型チェック |
publish |
全パッケージのコンパイル完了 | ローカルの Verdaccio レジストリへ公開 |
sandbox |
ローカルレジストリの起動 | フレームワーク用テストプロジェクトを生成 |
build |
サンドボックスの生成完了 | サンドボックスの Storybook をビルド |
e2e-tests |
サンドボックスの起動完了 | サンドボックスに対して Playwright テストを実行 |
すべての基盤となるのは compile タスク(25〜35行目)です。現在のプロジェクトに対して build-package.ts を実行し、^compile を依存として指定することで上流のパッケージを先にビルドし、本番環境向けの入力ファイルに基づいてキャッシュします。ルートに設定された parallel: 8 により、最大8パッケージを同時にコンパイルできます。
ヒント: Storybook にコントリビュートする際、コアパッケージだけをビルドするには
yarn nx compile storybookを実行します。^compileの依存設定により、上流のパッケージも自動的にビルドされます。直近のコミットから変更されたパッケージだけをビルドしたい場合はyarn nx affected -t compileが便利です。
パッケージコンパイルパイプライン
各パッケージのビルドは build-package.ts が担っています:
scripts/build/build-package.ts#L1-L80
flowchart TD
Start["build-package.ts"] --> ReadPkg["Read package.json"]
ReadPkg --> FindConfig["Find build-config.ts"]
FindConfig --> Prebuild{"Has prebuild?"}
Prebuild -->|Yes| RunPrebuild["Run prebuild script"]
Prebuild -->|No| Skip
RunPrebuild --> GenBundle["generateBundle()"]
Skip --> GenBundle
GenBundle --> |"For each entry category"| ESBuild["esbuild with platform-specific config"]
GenBundle --> GenTypes["generateTypesFiles()"]
GenBundle --> GenPkgJson["generatePackageJsonFile()"]
このスクリプトはパッケージの build-config.ts(第1回で解説)を読み込み、エントリカテゴリごとに異なる esbuild 設定でコンパイルします:
- Node エントリ:
platform: 'node'、Node.js 組み込みモジュールの使用を許可 - Browser エントリ:
platform: 'browser'、Chrome 100+/Safari 15+/Firefox 91+ をターゲット - Runtime エントリ: Browser と同様だが、コード分割を無効化(自己完結型スクリプトとして動作する必要があるため)
- Globalized runtime エントリ: エクスポートを
window.__STORYBOOK_*グローバル変数として公開するようラップ
コアパッケージにはプレビルドステップがあり(build-config.ts の8〜21行目)、メインビルドの前に generate-source-files.ts を実行してバージョンファイルや型の再エクスポートなど、派生するソースコードを生成します。
第1回で見たように、コアの build-config.ts はエントリを次のように分類します:
code/core/build-config.ts#L22-L210
12個の Node エントリ、23個の Browser エントリ、3個の Runtime エントリ、1個の Globalized runtime エントリは、それぞれ適切なプラットフォーム設定でコンパイルされます。この分類こそが、環境をまたいだ意図しないインポートを防ぐ仕組みです。fs をインポートしようとする Browser エントリはランタイムではなくビルド時に失敗するため、問題を早期に発見できます。
StoryIndexGenerator
StoryIndexGenerator はサーバーサイドのコンポーネントで、すべてのストーリーを検出してインデックス化する役割を担います。index.json を生成し、最終的にサイドバーを駆動するのがこのコンポーネントです:
code/core/src/core-server/utils/StoryIndexGenerator.ts#L101-L127
flowchart TD
Specifiers["stories: ['../src/**/*.stories.tsx']"] --> Glob["Glob filesystem"]
Glob --> Files["Matched files"]
Files --> Indexer{"Match indexer?"}
Indexer -->|"CSF indexer"| Parse["Parse with loadCsf()"]
Indexer -->|"MDX indexer"| MDXParse["Parse MDX"]
Parse --> Entries["Story entries + docs entries"]
MDXParse --> DocsEntry["Docs entry"]
Entries --> Cache["SpecifierStoriesCache"]
DocsEntry --> Cache
Cache --> Sort["Sort stories (storySortParameter)"]
Sort --> Index["StoryIndex (index.json)"]
ジェネレーターは2階層のキャッシュを保持しています:
- Specifier → Files キャッシュ: stories の glob パターンとマッチしたファイルの対応を管理
- File → Entries キャッシュ: 各ファイルとそこからパースされたストーリー/docs エントリの対応を管理
ファイルが変更された場合、そのファイルのキャッシュエントリだけが無効化されます。ジェネレーターはキャッシュされたすべてのエントリを結合し、重複を除去(docs よりストーリーを優先)したうえで、ユーザーの storySortParameter に従って並び替え、フルインデックスを再生成します。
第2回で見たように、common-preset に定義された CSF インデクサーは storybook/internal/csf-tools の loadCsf() を使ってストーリーファイルをパースします。これは AST ベースのパーサーで、ファイルを実行することなくストーリーのメタデータを抽出します。ストーリー名・タグ・パラメーターを静的解析のみで取得できる点が特徴です。
変更検知とスマートリビルド
ChangeDetectionService は比較的新しく追加されたコンポーネントで、開発中のインテリジェントなリビルド動作を実現します:
code/core/src/core-server/change-detection/ChangeDetectionService.ts#L87-L153
flowchart TD
Builder["Preview Builder (Vite)"] -->|"onModuleGraphChange"| CDS["ChangeDetectionService"]
CDS --> Debounce["Debounce 200ms"]
Debounce --> Git["GitDiffProvider.getChangedFiles()"]
Git --> Trace["findAffectedStoryFiles(moduleGraph, changedFiles)"]
Trace --> Status["Compute story statuses:\n - new\n - modified\n - affected"]
Status --> Store["StatusStore.set()"]
Store --> UI["Manager sidebar shows change indicators"]
このサービスは3つの入力を組み合わせて動作します:
- モジュールグラフの更新: ビルダー(Vite)から取得する依存関係情報
- Git diff:
GitDiffProviderから取得するベースブランチとの差分ファイル - ストーリーインデックス:
StoryIndexGeneratorから取得するファイルとストーリーのマッピング
ビルダーのモジュールグラフをトレースすることで、直接変更されたストーリーファイルだけでなく、間接的に影響を受けるファイルも検出できます。たとえば共有ユーティリティが変更された場合、それをインポートするすべてのストーリーが「affected」としてマークされます。ステータスは StatusStore に書き込まれ、Manager はそれを読み取ってサイドバーに変更インジケーターを表示します。
このサービスは features.changeDetection フラグで制御されており(common preset ではデフォルト無効)、builder 側でモジュールグラフのレポートをサポートしている必要があります。
サンドボックスシステム
Storybook がサポートするフレームワーク全体を対象としたインテグレーションテストには、サンドボックス生成システムを使用します。Nx のタスクグラフはその流れを明確に示しています:
compile → publish → run-registry → sandbox → build → serve → e2e-tests
各サンドボックスは特定のフレームワーク組み合わせ(例: react-vite/default-ts、angular/default-ts、svelte-vite/default-ts)に対して生成される実際のプロジェクトです。処理の流れは次のとおりです:
- Publish: 全パッケージをローカルの Verdaccio npm レジストリへ公開
- Sandbox: テンプレートプロジェクトを生成し、ローカルレジストリからパッケージをインストールして Storybook を設定
- Build: ビルドパイプラインを検証するためサンドボックスの Storybook をビルド
- E2E: 起動したサンドボックスに対して Playwright テストを実行
このアプローチにより、ユニットテストでは見落としがちなインテグレーションの問題を発見できます。フレームワーク固有の Vite plugin、プリセットの合成順序、HMR の動作、レンダラーの互換性など、実際のプロジェクトで発生しうる問題をすべて検証します。
nx.json ではこれらを ^production 入力を持つキャッシュ可能なターゲットとして定義しており、依存関係内のいずれかの本番ソースファイルが変更された時点でキャッシュが無効化されます。
構造化エラーシステム
Storybook では、StorybookError 抽象クラスをベースとした構造化エラー階層を採用しています:
code/core/src/storybook-error.ts#L25-L135
classDiagram
class StorybookError {
<<abstract>>
+category: string
+code: number
+documentation: boolean|string|string[]
+fromStorybook: true
+isHandledError: boolean
+subErrors: StorybookError[]
+fullErrorCode: string
+name: string
}
class ServerError {
category = "SERVER"
}
class PreviewError {
category = "PREVIEW_API"
}
class ManagerError {
category = "MANAGER_UI"
}
StorybookError <|-- ServerError
StorybookError <|-- PreviewError
StorybookError <|-- ManagerError
各エラーは次の要素を持ちます:
- category: エラーが発生した環境を示す識別子(例:
SERVER、PREVIEW_API、MANAGER_UI) - code: エラーを一意に識別する数値(4桁のゼロ埋め)
- documentation: ドキュメントへのリンク(省略可能)
fullErrorCode ゲッター(59行目)は SB_SERVER_0001 や SB_PREVIEW_API_0003 のような文字列を生成します。name ゲッターは SB_SERVER_0001 (MissingBuilderError) の形式でフォーマットします。
エラーの定義は3つの実行環境に対応する3つのファイルに分かれています:
server-errors.ts— Node.js サーバーのエラーpreview-errors.ts— Preview iframe のエラーmanager-errors.ts— Manager UI のエラー
fromStorybook: true フラグ(51行目)により、第1回で見た Preview ランタイムのエラーハンドラーが Storybook 起因のエラーとユーザーコードのエラーを区別し、テレメトリーへ適切にルーティングできます。
subErrors 配列(92行目)はエラーの集約をサポートします。親エラーに複数の関連する子エラーを持たせることができ、テレメトリーへ送信する際は親と各サブエラーがそれぞれ独立したイベントとして記録されます。
ヒント: Storybook にコントリビュートする際に新しいエラーを追加するときは、必ず対応する実行環境のエラーファイルを継承元とし、重複のないコード番号を割り当ててください。この構造化フォーマットにより、エラーの自動追跡が可能になり、ユーザーをドキュメントへ誘導しやすくなります。
シリーズを振り返って
6回にわたって、Storybook のアーキテクチャを外側の構造から内側の実装まで順を追って解説してきました。
- アーキテクチャ概要: 3つの実行環境、統合パッケージ、CLI のディスパッチ
- プリセットシステム: reduce チェーンによる設定の合成
- チャンネルとイベント: 環境をまたいだ通信プロトコル
- Preview レンダリング: CSF の処理、StoryRender ライフサイクル、フレームワークレンダラー
- Manager UI: モジュール化されたステート管理、アドオン統合、コンポジション
- ビルドシステム: Nx オーケストレーション、ストーリーインデックス、変更検知、構造化エラー
一貫したテーマとして浮かび上がるのは コンポジション という概念です。プリセットは設定を合成し、チャンネルは通信を合成し、モジュールはステートを合成し、ビルドシステムはパッケージを合成します。Storybook の力強さと複雑さは、同じ源泉から生まれています — すべての部品を独立して合成可能に保ちながら、全体としての一貫性を維持するという設計思想です。