🎭️

Playwright + GitHub Actions / Pagesで確認しやすいStorybookのVRT環境を作る

2025/01/14に公開

きっかけ

個人開発のプロジェクトでStorybookのVRTをやりたくなったので、Chromaticを導入しました。
https://www.chromatic.com/

手軽に導入でき、テストも安定していて満足していたのですが、無料枠がちょっと心許ない感じでした。

5,000 free snapshots per month
https://www.chromatic.com/pricing

有料プランは最安でも $149/ month と、個人で利用するには負担が大きい価格だったため、「それなら作っちゃおう!」と思ったのがきっかけです。

目指すもの

PR作成時 or PRへの追加コミット時に以下が実行される環境を作ります。

  • Storybookのユニットテスト
  • StorybookのVRT
    • VRTが失敗したらPRのコメントで通知
    • VRTの結果をWeb上で確認できる

環境

  • Storybook: v8.3.6
  • Playwright: v1.48.1

プロジェクト固有の部分について

  • ランタイムとパッケージマネージャーに Bun を使用
  • Bunのバージョン管理に mise を使用
  • main と develop ブランチに対するPRでのみVRTを実行

作っていく

Storybookのユニットテスト

これはドキュメントにあるサンプルをベースにしました。

https://storybook.js.org/docs/writing-tests/test-runner#run-against-non-deployed-storybooks

内容としては Storybook をビルドして、test-runner を実行するシンプルなものです。

storybook-test.yml(全体)
storybook-test.yml
name: Storybook Tests

on:
  push:
    branches:
      - main
      - develop
  pull_request:
    branches:
      - main
      - develop

jobs:
  storybook-test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup mise
        uses: jdx/mise-action@v2
        with:
          install: true
          cache: true

      - uses: actions/cache@v4
        with:
          path: ~/.bun/install/cache
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
          restore-keys: |
            ${{ runner.os }}-bun-

      - name: Install dependencies
        run: bun install

      # https://github.com/microsoft/playwright/issues/7249#issuecomment-1385567519
      - name: Get Playwright Version
        run: |
          PLAYWRIGHT_VERSION=$(bun pm ls | grep @playwright | sed 's/.*@//')
          echo "Playwright v$PLAYWRIGHT_VERSION"
          echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV

      - name: Cache Playwright Browsers
        id: cache-playwright-browsers
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}

      - name: Install Playwright
        if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
        run: bunx playwright install --with-deps

      - name: Serve Storybook and run tests
        run: |
          bunx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
            "bun preview-storybook" \
            "bunx wait-on tcp:127.0.0.1:6006 && bun test-storybook"

Playwrightをキャッシュして実行時間を短縮しています。

# https://github.com/microsoft/playwright/issues/7249#issuecomment-1385567519
- name: Get Playwright Version
  run: |
    PLAYWRIGHT_VERSION=$(bun pm ls | grep @playwright | sed 's/.*@//')
    echo "Playwright v$PLAYWRIGHT_VERSION"
    echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV

- name: Cache Playwright Browsers
  id: cache-playwright-browsers
  uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}

- name: Install Playwright
  if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
  run: bunx playwright install --with-deps

StorybookのVRT

テストコードを書く

Playwrightを使って各コンポーネントのスナップショットを撮るテストコードを書きます。

Storybookのビルド時に生成されるindex.json(各ストーリーの情報が入ったファイル)を読み込んで、スナップショットを撮って回ります。

画像周りが安定しないことが多いので、Issueを参考に画像を読み込むまで待つようにしています。
https://github.com/microsoft/playwright/issues/6046#issuecomment-1803609118

import { readFileSync } from "node:fs";
import test, { expect } from "@playwright/test";
import type { StoryIndex } from "@storybook/types";

// スナップショットを取得しないストーリー
const skip = [
  "hoge--default"
  // ここにスキップしたいストーリーのidを追加
];

const indexJson = readFileSync("storybook-static/index.json");

const json = JSON.parse(indexJson.toString()) as StoryIndex;

for (const [id, { tags }] of Object.entries(json.entries)) {
  // Play関数を持つストーリーはスナップショットを取得しない
  if (tags?.includes("play-fn")) {
    continue;
  }

  if (skip.includes(id)) {
    continue;
  }

  test(id, async ({ page }) => {
    const url = new URL("http://localhost:6006/iframe.html");
    url.searchParams.set("id", id);

    await page.goto(url.toString());

    // 画像の読み込みを待つ
    // https://github.com/microsoft/playwright/issues/6046#issuecomment-1803609118
    try {
      // NOTE: getByRole ではなく locator を使っているのは、SVG 画像を拾ってほしくないため
      for (const img of await page.locator("img").all()) {
        await expect(img).toHaveJSProperty("complete", true);
        await expect(img).not.toHaveJSProperty("naturalWidth", 0);
      }
    } catch (e) {
      // 失敗しても大丈夫なので無視
    }

    await expect(page).toHaveScreenshot(`${id}.png`, {
      fullPage: true
    });
  });
}

Playwrightの設定はこんな感じです。
撮影したスナップショットはプロジェクトルートの__snapshots__に保存しています。

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  snapshotPathTemplate: "../__snapshots__/{testFilePath}/{arg}{ext}",
  reporter: "html",
  timeout: 30 * 1000,
  retries: 1,
  fullyParallel: true,
  expect: {
    timeout: 10 * 1000
  },
  use: {
    baseURL: "http://localhost:6006",
    trace: "retain-on-failure"
  },
  projects: [
    {
      name: "chrome",
      use: {
        ...devices["Desktop Chrome"]
      }
    }
  ]
});

Expectedのスナップショットを保存するワークフロー

これはデフォルトブランチ(今回はdevelop)へのマージ時に実行されるようにします。

スナップショットは、GitHub Actionsのキャッシュに保存する方法を取りました。

storybook-vrt-update.yml(全体)
name: Storybook VRT Update

on:
  workflow_dispatch:
  push:
    branches:
      - develop

jobs:
  storybook-vrt-update:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup mise
        uses: jdx/mise-action@v2
        with:
          install: true
          cache: true

      - uses: actions/cache@v4
        with:
          path: ~/.bun/install/cache
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
          restore-keys: |
            ${{ runner.os }}-bun-

      - name: Install dependencies
        run: bun install

      # https://github.com/microsoft/playwright/issues/7249#issuecomment-1385567519
      - name: Get Playwright Version
        run: |
          PLAYWRIGHT_VERSION=$(bun pm ls | grep @playwright | sed 's/.*@//')
          echo "Playwright v$PLAYWRIGHT_VERSION"
          echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV

      - name: Cache Playwright Browsers
        id: cache-playwright-browsers
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}

      - name: Install Playwright
        if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
        run: bunx playwright install --with-deps

      - name: Serve Storybook and run tests
        run: |
          bunx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
            "bun preview-storybook" \
            "bunx wait-on tcp:127.0.0.1:6006 && bun playwright test -c .storybook/playwright.config.ts .storybook/ --update-snapshots"

      - name: Update Cache VRT snapshots
        if: success()
        uses: actions/cache/save@v4
        id: storybook-vrt-cache
        with:
          path: __snapshots__
          key: storybook-vrt-snapshots-${{ github.sha }}-${{ github.run_id }}

PR作成時にVRTを実行するワークフロー

まずは、VRTを実行して結果を保存するワークフローを作ります。

流れとしては以下の通りです。

  1. Restore Cache VRT snapshots でキャッシュからスナップショットを復元
  2. Serve Storybook and run tests でVRTを実行
  3. Store Artifacts でVRTの結果をArtifactsに保存

Artifactsは後続のジョブでデプロイするので、保存期間を7日間と短めにしています。

jobs:
  storybook-vrt:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup mise
        uses: jdx/mise-action@v2
        with:
          install: true
          cache: true

      - uses: actions/cache@v4
        with:
          path: ~/.bun/install/cache
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
          restore-keys: |
            ${{ runner.os }}-bun-

      - name: Install dependencies
        run: bun install

      # https://github.com/microsoft/playwright/issues/7249#issuecomment-1385567519
      - name: Get Playwright Version
        run: |
          PLAYWRIGHT_VERSION=$(bun pm ls | grep @playwright | sed 's/.*@//')
          echo "Playwright v$PLAYWRIGHT_VERSION"
          echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV

      - name: Cache Playwright Browsers
        id: cache-playwright-browsers
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}

      - name: Install Playwright
        if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
        run: bunx playwright install --with-deps

      - name: Restore Cache VRT snapshots
        uses: actions/cache/restore@v4
        id: storybook-vrt-cache
        with:
          path: __snapshots__
          key: storybook-vrt-snapshots-${{ github.sha }}-${{ github.run_id }}
          restore-keys: |
            storybook-vrt-snapshots-

      - name: Serve Storybook and run tests
        run: |
          bunx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
            "bun preview-storybook" \
            "bunx wait-on tcp:127.0.0.1:6006 && bun playwright test -c .storybook/playwright.config.ts .storybook/"

      - name: Store Artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: vrt-report
          path: playwright-report/
          retention-days: 7

次にテストレポートをデプロイするジョブを作ります。

これは以下のブログ記事を参考にしました。
https://ysfaran.github.io/blog/2022/09/02/playwright-gh-action-gh-pages/#publish-html-report-to-github-pages

空っぽの gh-pages ブランチを用意しておいて、そこにテストレポートをpushし、GitHub Pagesで公開するという流れです。

記事内では触れられていませんが、permissionsの設定がないとpushできないので注意が必要です。

# レポートを GitHub Pages にデプロイ
# https://ysfaran.github.io/blog/2022/09/02/playwright-gh-action-gh-pages/#publish-html-report-to-github-pages
deploy-report:
  name: Deploy VRT Report
  permissions:
    contents: write
    pull-requests: write
  # テスト失敗時のみ
  if: failure()
  needs: storybook-vrt
  runs-on: ubuntu-latest
  continue-on-error: true
  concurrency:
    group: ${{ github.ref_name }}
    cancel-in-progress: true
  env:
    HTML_REPORT_URL_PATH: reports/${{ github.ref_name }}/${{ github.run_id }}/${{ github.run_attempt }}
  steps:
    - name: Checkout GitHub Pages Branch
      uses: actions/checkout@v4
      with:
        ref: gh-pages

    - name: Set Git User
      # see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
      run: |
        git config --global user.name "github-actions[bot]"
        git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

    - name: Download zipped HTML report
      uses: actions/download-artifact@v4
      with:
        name: vrt-report
        path: ${{ env.HTML_REPORT_URL_PATH }}

    - name: Push HTML Report
      timeout-minutes: 3
      # commit report, then try push-rebase-loop until it's able to merge the HTML report to the gh-pages branch
      # this is necessary when this job is running at least twice at the same time (e.g. through two pushes at the same time)
      run: |
        git add .
        git commit -m "🍱 VRTのレポートを追加 (${{ github.run_id }} / attempt: ${{ github.run_attempt }})"

        max_attempts=100
        attempt=1

        while [ $attempt -le $max_attempts ]; do
          if git pull --rebase; then
            if git push; then
              echo "デプロイに成功"
              exit 0
            else
              echo "pushに失敗"
            fi
          else
            echo "rebaseに失敗"
          fi

          attempt=$((attempt + 1))
          echo "再試行: $attempt / $max_attempts"
        done

        echo "最大試行回数($max_attempts)に達しました"
        exit 1

PRにテストレポートのURLをコメントするステップを追加すれば完成です。
以下の記事を参考にしました。

https://blog.omuomugin.com/posts/2024-06-03/

- name: Create comment
  run: |
    {
      echo "COMMENT_BODY<<EOF"
      echo "## 🚨 VRTが失敗しました"
      echo "テストレポートを確認してください!"
      echo "> https://yondako.github.io/yondako/$HTML_REPORT_URL_PATH"
      echo EOF
    } >> "$GITHUB_ENV"

- name: Comment to PR
  # コメントが無い場合 --edit-last が失敗するので、失敗したらコメントを追加する
  # 参考: https://blog.omuomugin.com/posts/2024-06-03/
  run: gh pr comment ${{ github.ref_name }} --body "${{ env.COMMENT_BODY }}" --edit-last || gh pr comment ${{ github.ref_name }} --body "${{ env.COMMENT_BODY }}"
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

少し長いですが、全体はこのようになります。

storybook-vrt-pr.yml(全体)
name: Storybook VRT

on:
  push:
    branches-ignore: [main, develop, gh-pages]

jobs:
  storybook-vrt:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup mise
        uses: jdx/mise-action@v2
        with:
          install: true
          cache: true

      - uses: actions/cache@v4
        with:
          path: ~/.bun/install/cache
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
          restore-keys: |
            ${{ runner.os }}-bun-

      - name: Install dependencies
        run: bun install

      # https://github.com/microsoft/playwright/issues/7249#issuecomment-1385567519
      - name: Get Playwright Version
        run: |
          PLAYWRIGHT_VERSION=$(bun pm ls | grep @playwright | sed 's/.*@//')
          echo "Playwright v$PLAYWRIGHT_VERSION"
          echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV

      - name: Cache Playwright Browsers
        id: cache-playwright-browsers
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}

      - name: Install Playwright
        if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
        run: bunx playwright install --with-deps

      - name: Restore Cache VRT snapshots
        uses: actions/cache/restore@v4
        id: storybook-vrt-cache
        with:
          path: __snapshots__
          key: storybook-vrt-snapshots-${{ github.sha }}-${{ github.run_id }}
          restore-keys: |
            storybook-vrt-snapshots-

      - name: Serve Storybook and run tests
        run: |
          bunx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
            "bun preview-storybook" \
            "bunx wait-on tcp:127.0.0.1:6006 && bun playwright test -c .storybook/playwright.config.ts .storybook/"

      - name: Store Artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: vrt-report
          path: playwright-report/
          retention-days: 7

  # レポートを GitHub Pages にデプロイ
  # https://ysfaran.github.io/blog/2022/09/02/playwright-gh-action-gh-pages/#publish-html-report-to-github-pages
  deploy-report:
    name: Deploy VRT Report
    permissions:
      contents: write
      pull-requests: write
    # テスト失敗時のみ
    if: failure()
    needs: storybook-vrt
    runs-on: ubuntu-latest
    continue-on-error: true
    concurrency:
      group: ${{ github.ref_name }}
      cancel-in-progress: true
    env:
      HTML_REPORT_URL_PATH: reports/${{ github.ref_name }}/${{ github.run_id }}/${{ github.run_attempt }}
    steps:
      - name: Checkout GitHub Pages Branch
        uses: actions/checkout@v4
        with:
          ref: gh-pages

      - name: Set Git User
        # see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

      - name: Download zipped HTML report
        uses: actions/download-artifact@v4
        with:
          name: vrt-report
          path: ${{ env.HTML_REPORT_URL_PATH }}

      - name: Push HTML Report
        timeout-minutes: 3
        # commit report, then try push-rebase-loop until it's able to merge the HTML report to the gh-pages branch
        # this is necessary when this job is running at least twice at the same time (e.g. through two pushes at the same time)
        run: |
          git add .
          git commit -m "🍱 VRTのレポートを追加 (${{ github.run_id }} / attempt: ${{ github.run_attempt }})"

          max_attempts=100
          attempt=1

          while [ $attempt -le $max_attempts ]; do
            if git pull --rebase; then
              if git push; then
                echo "デプロイに成功"
                exit 0
              else
                echo "pushに失敗"
              fi
            else
              echo "rebaseに失敗"
            fi

            attempt=$((attempt + 1))
            echo "再試行: $attempt / $max_attempts"
          done

          echo "最大試行回数($max_attempts)に達しました"
          exit 1

      - name: Create comment
        run: |
          {
            echo "COMMENT_BODY<<EOF"
            echo "## 🚨 VRTが失敗しました"
            echo "テストレポートを確認してください!"
            echo "> https://yondako.github.io/yondako/$HTML_REPORT_URL_PATH"
            echo EOF
          } >> "$GITHUB_ENV"

      - name: Comment to PR
        # コメントが無い場合 --edit-last が失敗するので、失敗したらコメントを追加する
        # 参考: https://blog.omuomugin.com/posts/2024-06-03/
        run: gh pr comment ${{ github.ref_name }} --body "${{ env.COMMENT_BODY }}" --edit-last || gh pr comment ${{ github.ref_name }} --body "${{ env.COMMENT_BODY }}"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

ブランチの削除時にテストレポートを削除するワークフロー

PRをマージした後にテストレポートを削除するワークフローも用意しておきます。

これは先程のブログで紹介されているものをほぼそのまま使いました 🙏

storybook-vrt-delete.yml(全体)
name: Delete Storybook VRT Report

on:
  delete:
    branches-ignore: [main, develop, gh-pages]

concurrency:
  group: ${{ github.event.ref }}
  cancel-in-progress: true

jobs:
  delete-storybook-vrt-report:
    name: Delete Storybook VRT Report
    permissions:
      contents: write
    runs-on: ubuntu-latest
    env:
      BRANCH_REPORTS_DIR: reports/${{ github.event.ref }}
    steps:
      - name: Checkout GitHub Pages Branch
        uses: actions/checkout@v4
        with:
          ref: gh-pages

      - name: Set Git User
        # see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

      - name: Check for workflow reports
        run: |
          if [ -z "$(ls -A $BRANCH_REPORTS_DIR)" ]; then
            echo "BRANCH_REPORTS_EXIST="false"" >> $GITHUB_ENV
          else
            echo "BRANCH_REPORTS_EXIST="true"" >> $GITHUB_ENV
          fi

      - name: Delete reports from repo for branch
        if: ${{ env.BRANCH_REPORTS_EXIST == 'true' }}
        timeout-minutes: 3
        run: |
          cd $BRANCH_REPORTS_DIR/..

          rm -rf ${{ github.event.ref }}
          git add .
          git commit -m "🔥 ${{ github.event.ref }}のVRTのレポートを削除"

          max_attempts=100
          attempt=1

          while [ $attempt -le $max_attempts ]; do
            if git pull --rebase; then
              if git push; then
                echo "デプロイに成功"
                exit 0
              else
                echo "pushに失敗"
              fi
            else
              echo "rebaseに失敗"
            fi

            attempt=$((attempt + 1))
            echo "再試行: $attempt / $max_attempts"
          done

          echo "最大試行回数($max_attempts)に達しました"
          exit 1

完成形

PRを作成するとGitHub Actionsが実行されて、
CIのステータス

VRTが失敗すると、PRにコメントが追加されます。
VRTの失敗をPRのコメントで通知している様子

URLにアクセスすると、テストレポートが表示されて、
テストレポート

スライダーでスナップショットの比較もできます 🙌
スナップショットの比較

まとめ

GitHub Pagesで公開することで、VRTのテストレポートを見るためにArtifactsをダウンロードする手間が省け、より良い開発体験を得られるようになりました!

Publicリポジトリであればコストもかからないので、個人開発で使う分にはちょうど良いかなと思います。

今回のワークフローは以下のリポジトリで使用しています。
https://github.com/yondako/yondako/tree/develop/.github/workflows

chot Inc. tech blog

Discussion