Storybook を継続的に管理するためのツールを作成しました

  • URLをコピーしました!

こんにちは、PR TIMES でインターンをしている水野です。

今回、社内運用の Storybook 上で閲覧できなくなった story を発見するために、全 story を定期的に巡回するクローラを作成したのでご紹介します。

目次

作成の背景

PR TIMES では2022年よりコンポーネントの継続的な保守管理に加えて、エンジニアとデザイナー・QAチームとの連携をより強くし開発効率を高めることを目的として Storybook を導入し始めました。

あわせて読みたい
Storybookを導入した話 こんにちは。開発本部でインターンをしている桐澤です。インターンの業務では主にReactにリプレイスされたページのリファクタリングやコンポーネント修正を行ってきまし...

導入以降、Storybook は活発に使用されていて、フロントエンドで管理している story の合計は約1,300個に及び、現在でもその数は増加し続けています。

これらの story は S3 を用いて社内の全員に対して公開しており、誰でもいつでも閲覧できるようになっています。

しかし、コンポーネントへの変更を story 側に反映し忘れることで、story が閲覧できなくなることがありました。例えば、Props の変更や、新たに必要になった Provider ・ API 通信への対応が story 側で漏れ、レンダリング時にエラーが発生するケースです。

壊れている story は基本的に発見した人が直す形で、特に発見体制や保守方法については決められていませんでした。

この運用では、発見されない story は壊れたまま公開され続けます。その結果、利用者がコンポーネントの表示を確認できず、UI カタログとして十分に活用できなくなります。

そこで、今回は壊れた story を無くし、継続的に Storybook を閲覧できる状態を作るためにクローラを作成しました。

実際に壊れているstoryの画面をスクリーンショットしたもの

どうやって解決したか

S3 にデプロイされている Storybook にアクセスし、閲覧できない story を探すクローラを独自実装することで、この問題を解決しました。

この方法を選ぶにあたり、次の2つを検討しました。

  • Storybook 公式の Vitest Addon を導入し、各 story のレンダリング時にエラーが発生しないかを確認する
  • クローラを独自実装し、社内に公開されている Storybook を巡回して、各 story が閲覧できるかを確認する

クローラの独自実装を選んだ理由を、PR TIMES の Storybook の構成とあわせて説明します。

Storybook の状況

  • PR TIMES のフロントエンドはモノレポを採用している
  • それぞれのプロジェクトが Storybook を個別に持っている
  • S3 にデプロイされている各プロジェクトの Storybook は異なる URL

各プロジェクトの Storybook の設定は次のように配置されています。

prtimes-frontend/
└── apps/
    ├── project-a/
    │   ├── .storybook/
    │   ├── package.json
    │   ├── src/
    │   └── tsconfig.json
    ├── project-b/
    │   ├── .storybook/
    │   ├── package.json
    │   ├── src/
    │   └── tsconfig.json
    ├── project-c
    │   ├── .storybook/
    │   ├── package.json
    │   ├── src/
    │   └── tsconfig.json
    └── project-d/
        ├── .storybook/
        ├── package.json
        ├── src/
        └── tsconfig.json 

それぞれのプロジェクトが持っている Storybook を S3の異なるバケットにアップロードしホスティングすることで社内に公開しています。

GitHub Actionsを通して各プロジェクトのStorybookをS3のバケットにホスティングしている画像

独自実装を選んだ理由

今回の目的は、利用者目線で Storybook の各 story が正常に閲覧できることを確認することです。
Vitest 上で story が正常に動作していても、ビルドやデプロイを経た公開先で同じように表示できるとは限りません。そのため、利用者が実際にアクセスする、S3 にデプロイされた Storybook を直接確認する必要がありました。

Vitest Addon はテスト環境で story の動作を検証するため、S3 に公開された Storybook を利用者が閲覧できるかどうかは、別途確認する必要があります。加えて、導入には各プロジェクトのテスト環境への設定追加も必要です。

そこで今回は、公開先を直接確認でき、各プロジェクトの設定変更も不要なクローラを独自実装しました。これにより、異なる URL で公開されている Storybook を共通の処理で巡回し、利用者が閲覧する環境で各 story の表示を確認できるようにしました。

クローラの設計方針

クローラの導入にあたっては、既存の story ファイルにタグを付与したり、ファイル名を変更したりする必要がない方針にしました。

これにより、巡回対象にするために既存の story を修正する必要がなく、新しい story を追加する際にも、クローラのための特別な記述ルールを設けずに済むようにしました。

実装

今回作成したクローラは S3 上にデプロイされている Storybook の各 story を Playwright で巡回するものです。

以下では、クローラの処理を抜粋しながら説明して行きます。

PlaywrightがS3にホスティングされているStorybookプロジェクトをクロールしている画像

1. 各 story にアクセスするための URL を組み立てる

デプロイ済みの Storybook から各 story の情報を取得し、アクセス先の URL を組み立てる手順は次のとおりです。

1-1.Storybook の URL の末尾に /index.jsonをつけてアクセス

/index.jsonにアクセスすることでその Storybook が持っている全ての story の情報を取得できます

{
  "v": 5,
  "entries": {
    "button-primary--default": {
      "id": "button-primary--default",
      "title": "Button/Primary",
      "name": "Default",
      "importPath": "./src/components/Button/Button.stories.tsx",
      "type": "story",
      "tags": ["dev", "test"]
    },
      "button-primary--secondary": {
      "id": "button-primary--secondary",
      "title": "Button/Secondary",
      "name": "Secondary",
      "importPath": "./src/components/Button/Button.stories.tsx",
      "type": "story",
      "tags": ["dev", "test"]
    }
  }
}
1-2.index.json に格納されている id を基に URL を組み立てる

index.json には id、title、type、などの基本的な情報が全て格納されていて、これらの情報を基に story にアクセスするための URL を構築します

// 引数で受け取った URL を元に story 取得する
const baseUrl = new URL(
  storybookUrl.endsWith("/") ? storybookUrl : `${storybookUrl}/`,
);
const indexUrl = new URL("index.json", baseUrl);
const index = await fetch(indexUrl).then((res) => res.json());

// docs を除き、canvas で表示する story だけを巡回対象にする
const stories = Object.values(index.entries)
	  .filter((entry) => entry.type === "story")
	  .map((story) => {
  // iframe を直接開く URL を作る
  const storyUrl = new URL("iframe.html", baseUrl);
  storyUrl.searchParams.set("id", story.id);
  storyUrl.searchParams.set("viewMode", "story");

  return {
    id: story.id,
    title: story.title,
    name: story.name,
    url: storyUrl.href,
  };
});

2. 取得した story を表示

index.json で取得した story データを基にして URL を構築しますが、この時に index.html ではなく iframe.html を指定することが重要になってきます。

例えば、Storybook の公開先が https://aws-storybook.amazonaws.com/project-a/ 、story の ID が button-primary--default の場合、次の URL を組み立てます。

https://aws-storybook.amazonaws.com/project-a/iframe.html?id=button-primary--default&viewMode=story

id には表示する story の ID を指定し、 viewMode=story で story の表示モードを指定します。

iframe.html を指定すると、本来 Storybook を開いた時に表示されるサイドバーやツールバーなどの story 管理のための要素が表示されず、story のみが表示されるようになります。

index.htmlとiframe.htmlの画面表示の違い

iframe.html にすることでページ自体の DOM 構造を単純化することができるため、クロール時にテストしやすくなります。

今回のクローラではこの iframe.html をクロールし、通信や実行時エラー、画面の表示状態を監視することで story が壊れているかどうかを調べています。

先ほど組み立てた URL に Playwright で page.goto() を利用して順番にアクセスします。

// 組み立てた story の URL を利用して全ての story にアクセスする
for (const story of stories) {
  await page.goto(story.url);
}

壊れている story の条件

今回は次の2つを失敗の条件としました。

  • story の表示確認中に、未捕捉の実行時エラーが発生した場合
  • API 通信エラーが起き、画面が真っ白になっている場合(story の表示自体ができなくなっている)

未捕捉の実行時エラーは表示や動作に影響する可能性があるため、画面が表示されていても失敗として扱います。一方、API 通信に失敗しても、コンポーネントが表示されていれば UI カタログとして利用できます。そのため、API 通信エラーが発生し、かつ画面に何も表示されていない場合に失敗と判定します。

失敗の判定に必要なエラー情報を集めるため、各 story にアクセスする前に、実行時のエラーとAPI通信エラーの監視を行います。pageerror で実行時のエラーを記録し、response と requestfailed で API の HTTP エラーレスポンスや通信の失敗を記録します。

// story ごとに error を初期化
const pageErrors: Error[] = [];
const apiErrors: string[] = [];

const onPageError = (error: Error) => {
  pageErrors.push(error);
};

const onResponse = (response: Response) => {
  // リクエストが fetch/xhr による API 通信かを判定する
  if (isApiRequest(response.request()) && !response.ok()) {
    apiErrors.push(
      `${response.status()} ${response.request().method()} ${response.url()}`,
    );
  }
};

const onRequestFailed = (request: Request) => {
  if (isApiRequest(request)) {
    apiErrors.push(`${request.method()} ${request.url()}`);
  }
};

page.on("pageerror", onPageError);
page.on("response", onResponse);
page.on("requestfailed", onRequestFailed);

監視を開始したら、story の URL にアクセスします。アクセス直後の状態で判定しないよう、読み込みの猶予として、通信が落ち着くまで最大5秒待ちます。

通信が落ち着かなくても、コンポーネント自体は表示されている場合があるため、 networkidle の待機がタイムアウトだけでは失敗とせず、表示状態の確認に進みます。その時点で未捕捉の実行時エラーが記録されているか、または API 通信エラーが発生して画面に何も表示されていないかを確認し、失敗を判定します。

try {
  await page.goto(story.url);

  // story の描画と通信が落ち着くのを待つ
  await page.waitForLoadState("networkidle", {timeout: 5000}).catch(() => {});

  // story の表示要素があるか調べ、画面が真っ白かを判定する
  const isBlankStory = await isStoryDisplayBlank(page);

  const isBrokenStory =
    pageErrors.length > 0 ||
    (apiErrors.length > 0 && isBlankStory);

  // isBrokenStory が true の場合はテストを失敗させる
} finally {
  // 次の story に関し処理が重複して登録されないように監視を解除
  page.off("pageerror", onPageError);
  page.off("response", onResponse);
  page.off("requestfailed", onRequestFailed);
}

クローラを作成するにあたって詰まった点

今回クローラを作成するにあたって詰まった点は大きく以下の2つでした。

  • どんなエラーをテストの失敗条件にするか
  • 意図通りに動いている story を誤検知しないこと

エラーを厳しく取ろうとすると失敗箇所が増え、story 側の修正量が膨大になってしまうことに加え、今回のクローラでは全プロジェクトを横断したテストを実行するため、特定プロジェクトに依存しない汎用的な判定条件に絞りました。

特に難しかったのは、コンポーネントが表示されない story の判定です。描画先の root 要素が空であることを条件にすると、正常な story が何個も引っかかってしまいました。

これは、Props の値に応じてコンポーネントが表示されないことを確認する story も、root 要素が空であることを理由に失敗と判定していたためです。また、React Portal を利用する modal や dialog では、コンポーネントが story の root 要素の外側に描画されるため、表示できていてもテストが失敗判定されていました。

A screenshot of HTML code showing a structure for a Storybook application, with annotations in Japanese indicating the locations of component displays and a React portal.

表示要素を正しく判定するため、確認範囲と判定方法の2点を見直しました。

まず、Portal で描画されるコンポーネントに対応するために、story の root 内だけでなく、Storybook が管理する要素を除いた body 配下も確認するようにしました。

次に、その範囲にある要素が実際に画面に見えているかを確認しました。ここで注意が必要だったのが、Playwright の :visible の判定です。 :visible は opacity: 0 の要素も「ブラウザに表示されている要素」として判定されるため、たとえ画面が真っ白になっていても「表示要素がある」と判定してしまいます。

そこで、要素自身と祖先要素の opacity も確認し、いずれかが0の場合は表示要素として数えないようにしました。

 // :visible で取得した候補から、透明になっていない要素を探す
  const hasVisibleUserElement = await visibleUserElements.evaluateAll(
    (elements) =>
      elements.some((element) => {
        let currentElement: Element | null = element;

        // 親要素が透明な場合も、その子要素は見えなくなるため確認する
        while (currentElement !== null) {
          const opacity = Number(
            globalThis.getComputedStyle(currentElement).opacity,
          );

          if (opacity === 0) {
            return false;
          }

          currentElement = currentElement.parentElement;
        }

        return true;
      }),
  );

この確認で表示要素が一つも見つからなければ、画面が真っ白な状態と判定するようにしました。

また、意図的に何も表示しない story を誤検知しないよう、表示要素がないことだけでは失敗とせず別の条件と組み合わせることにしました。

PR TIMES では、story 内の API 通信に対してモックのレスポンスを返すために Mock Service Worker(MSW)を利用しています。モックの設定漏れによって通信に失敗する story も多く見られたため、表示要素がないことと API 通信エラーを組み合わせて判定することにしました。

以上のような PR TIMES のフロントエンド実装を考慮したクロールの条件設定に苦労しました。

クローラを継続的に実行する仕組み

クローラを作成するだけでは、実行が継続されなかったり、検知結果が見落とされる可能性があります。そこで、GitHub Actions Workflow を利用してクロールから結果の通知までを自動化しました。

Workflow の実行は毎週月曜日の朝に行われ、各プロジェクトをクロールします。壊れた story を検知した場合、Playwright のレポートを S3 に保存し、実行が失敗したことを Slack へ通知します。

Slack の通知では Devin にメンションし、エラーの内容から修正 PR を作成してもらうようにしています。

クローラの実行で失敗した時に来るSlack通知の画像

また、実行環境については、約1,300件の story を全てクロールすると、Workflow の完了までに1時間ほどかかるため、EC2 上に構築した GitHub Actions の self-hosted runner を利用しています。長時間の実行を安定させ、GitHub-hosted runner の利用量を抑えることを目的としています。

この仕組みを導入することによって、壊れた story が誰かに偶然発見されるのを待つのではなく、週次で検知してエンジニアへ共有できるようになりました。

今後の課題

今回のクローラで壊れた story を検知できるようにはなりましたが、運用としてはまだ整えきれていない部分があるので以下をこれから整備していきたいと思っています。

  • 修正フローの運用ルール化
  • 検知条件の拡充
  • API モック漏れ修正

まとめ

今回は PR TIMES フロントエンドで運用している Storybook を継続的に運用するために、壊れた story を検知するためのクローラを作成しました。初めて自分から提言して作成したツールで、要件の整理からチケット作成、実装まで一気通貫でやることができとても良い経験になりました。

特に、壊れた story をどのように定義し、判定するかは、当初の見通しが甘かった部分でした。意図的に何も表示しない story や、通信に失敗しても表示できる story があり、単純な条件では判断できませんでした。事前にコードベースの調査や正常、異常の定義を明確にしながら進めることが重要だと学びました。

今後は修正フローや検知条件を整備し、 Storybook を継続的に閲覧できる状態へ保つ仕組みにしていきたいです。

  • URLをコピーしました!

この記事を書いた人

インターンをしています。

目次