GitHub ActionsのみでVRTを実現したい人へ

## 著者の自己紹介

著者はイナバくん。
株式会社ニジボックスのフロントエンドエンジニア。
登山家でもあり、禁煙家でもあります。

某アパレル企業に2年ほど常駐していて、自社プロダクトを開発しております。

## この記事で話すこと

GitHub Actionsを使ってVRTを実現する方法をお話しします。

## この記事で話さないこと

- VRTとは何か？ について
- GitHub Actionsの基本的な使い方や文法について
- storycap・reg-suitなどの基本的な使い方について

また、VRTの構成として有名な下記のサービスたちは使っていません。

- [Chromatic](https://www.chromatic.com/): Storybookのホスティングサービス
- AWSのS3: VRTのスクショやレポートのアップ先としてよく使われます

あくまでGitHubのみでVRTを実現する方針となります。

## VRT導入の背景

私の参画しているプロダクトでは、フロントエンドは Next.jsで作られています。

UIコンポーネントの管理にはStorybookを使っています。

また、
- ユニットテスト(Jest)
- インテグレーションテスト(Testing-library)
- E2Eテスト(Playwright)

はCIにて導入済になります。

CIでUIの変更も検知するためにVRTを導入することになりました。

## VRTのツール選定

まず最初に、下記の構成が候補としてありました。

1. Chromatic
2. Playwright
3. Storycap × reg-suit

また、ツール選定の判断軸として重視していたのは、下記の3点になります。

1. コストの低さ: VRTにかけられる予算が限られていたため
2. テストの網羅性
3. テストの実行時間が短いこと

### ツール① Chromatic ❌

ChromaticはStorybook公式のホスティングサービスです。
GitHubと連携することでブランチごとのStorybookをホスティングして、VRTを実行できます。

Chromaticは、VRTにおける
- UI比較
- スクショ保存
- レポート表示
の機能がデフォルトで搭載されているので、Storybookを使っているプロダクトであれば、簡単にVRTを始められます。

ただ、ChromaticはStorybookのデプロイに対して従量課金でコストが発生します。
なので実際に運用するとなる場合は、特定のPR・ブランチのみVRTを実行するようなルールが多いです。
ただ、そのルールだと毎コミット・PRでVRTを実行できないので、「テストの網羅性」が低くなります。

なので、「コストが高い」と「テストの網羅性が低い」の2つの理由で、Chromaticは却下となりました。

(もしUIコンポーネントのデザインレビューなど、Chromaticの機能をフル活用するプロダクトだった場合はオススメです)

### ツール② Playwright ❌

Playwrightでは、下記の手順でVRTを実行するパターンをよく見ます。

1. Storybookをビルドして、`stories.json` を生成する(各Storyのpath・titleなどの情報が記載されている)
2. ビルドしたStorybookのHTMLファイルをローカルサーバーで立ち上げる
3. Playwrightでブラウザを立ち上げて、2のStorybookにアクセスする
4. 各Storyに対して、`toHaveScreenshot()`でVRTを実行するテストケースを書く

こちらは実際にローカルで実装してみたのですが、Storycap × reg-suitと比べると、テスト実行時間が長いです。
なぜなら、PlaywrightはVRT専用のツールではなく、あくまでE2Eテストツールであるためです。
(毎テストケースごとにブラウザを立ち上げる時間もかかります)

そのため、このPlaywrightも不採用としました。

### ツール③ Storycap × reg-suit ⭕

実際に採用したのが、Storycap × reg-suitです。

消去法で決まりましたが、これらのツールのメリットは下記になります。

- コストが0
- テスト実行時間が早い
- Storybookを活用できるのでテストケース作成の必要がない

## VRTの構成

下記のツール・サービスを使った構成となりました。

- Storybook: UIコンポーネント管理
- Storycap: Storybookの各Storyのスクショを取る
- reg-suit: 2つの画像を比較して差分を検出するVRTツール(レポートも生成できる)
- GitHub Actions: GitHub のPR上でVRTを実行する
- GitHub Page: reg-suitが生成したVRTレポートをホスティングする
- GitHubのArtifact・キャッシュ: スクショの保存先

メリットは下記になります。

1. コストが0
2. テストの網羅性が高い: 全PRの毎コミットごとに、全テストケースを実行できる
3. AWSやChromaticなどの他サービスに依存せず、VRTを導入できる

逆にデメリットとしては、下記がありました。

- GitHub Actionsのワークフロー実装に工数がかかる(他の構成より時間がかかる)

ただ、プロダクトの特性上、導入工数をかけることはOKだったため、こちらの構成となりました。

## VRTの運用フロー

下記のようなフローでVRTを回しています。

1. featureブランチからmainブランチ向けにPRを出す
2. VRTのCIが実行される
3. GitHub Actionのキャッシュに、mainブランチのStorybookスクショがあるか確認
4. スクショが無かったら、スクショ撮影して、GitHubのキャッシュへ保存
5. main・featureのスクショ比較
6. GitHubのbotがVRTの結果レポートをPRにコメント
7. レビュワーがVRTレポートでUIの変更を確認
8. UI変更に問題なければapproveを付けて、PRをmergeする
9. PRをmainにmergeすると、mainのStorybookスクショが撮影されて、キャッシュに保存される

## GitHub ActionsでのVRT設定方法

### npm scriptの準備

まずnpm scriptは下記のように設定しておきます。

```json:package.json
{
  "scripts": {
    "build-storybook": "storybook build",
    "storycap": "storycap --serverTimeout 60000 --captureTimeout 10000 --serverCmd \"npx http-server storybook-static -p 9003\" http://localhost:9003",
    "reg-suit": "reg-suit run"
  }
}
```

### ワークフロー実装 Job① 正となる画像の準備(mainブランチのスクショ)

```yml:./.github/workflows/vrt.yml
take_expected_screenshots:
  name: Take Expected Screenshots
  runs-on: ubuntu-latest
  timeout-minutes: 30
  steps:
    - name: Check out code
      uses: actions/checkout@v4
      with:
        ref: main # mainブランチのスクショをVRTの正の画像とする

    - name: Restore cached expected screenshots # キャッシュに保存されたスクショがあったら再利用する
      id: expected_screenshots_cache
      uses: ./.github/composite_actions/cache-screenshots

    - name: Client setup
      if: ${{ steps.expected_screenshots_cache.outputs.cache-hit != 'true'}}
      uses: ./.github/composite_actions/client-setup

    - name: Restore or build storybook # キャッシュにスクショが保存されてなかったらStorybookをビルドしてスクショ撮影する
      if: ${{ steps.expected_screenshots_cache.outputs.cache-hit != 'true'}}
      uses: ./.github/composite_actions/cache-storybook-static

    - name: Install Chrome for puppeteer
      if: ${{ steps.expected_screenshots_cache.outputs.cache-hit != 'true'}}
      run: npx puppeteer browsers install chrome

    - name: Take expected screenshots
      if: ${{ steps.expected_screenshots_cache.outputs.cache-hit != 'true'}}
      run: npm run storycap

    - name: Upload expected screenshots to artifact # 他のstepでスクショを使うのでartifactにアップする
      uses: actions/upload-artifact@v4
      with:
        name: expected-screenshots
        path: __screenshots__
        retention-days: 1
```

GitHub はワークフロー内で[GitHubのキャッシュ](https://docs.github.com/ja/actions/using-workflows/caching-dependencies-to-speed-up-workflows)にデータを保存したりダウンロードする事ができます。  
キャッシュ内にあるデータは別のPRから参照することもできるため、mainブランチのスクショは他のPRで再利用ができます。

(キャッシュの保存期間や容量はレポジトリ設定によって異なるので、実際に使う前に確認しましょう。)

### Job内で使っているComposite Action

[Composite Action](https://docs.github.com/ja/actions/creating-actions/creating-a-composite-action) とはワークフロー内で再利用できるアクションです。

他ワークフローでも使いたい処理や、頻出するアクションはComposite Actionにしています。

#### セットアップ処理(npm installなど)

```yml:./.github/composite_actions/client-setup/action.yml
name: "Client Setup"
description: "FEのCIの各Jobに必要なセットアップ処理をまとめたAction"

runs:
  using: "composite"
  steps:
    - name: Setup Node
      uses: actions/setup-node@v4
      with:
        node-version: 20
        cache: "npm"
        cache-dependency-path: package-lock.json
    # キャッシュされたnode_modulesを取得する
    - name: Restore node_modules Cache
      id: node_modules_cache
      uses: actions/cache@v4
      with:
        path: node_modules
        key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} # パッケージの依存関係が変わっていたらキャッシュのキーが変わります
    # キャッシュが存在しなかったら、npm installする(Job終了時にnode_modulesはキャッシュされる)
    - name: Install Dependencies
      if: ${{ steps.node_modules_cache.outputs.cache-hit != 'true'}}
      run: npm install
      shell: bash
```

CIの実行時間の短縮のために、`package-lock.json`が変更された場合のみ、`npm install`するようにしています。

#### キャッシュされたmainブランチのスクショの再利用

```yml:./.github/composite_actions/cache-screenshots/action.yml
name: "Cache Expected Screenshots"
description: "キャッシュに保存されているスクショを再利用する。(保存されていなかった場合はアップする)"
outputs:
  cache-hit:
    description: "キャッシュが存在するかどうか"
    value: ${{ steps.expected_screenshots_cache_id.outputs.cache-hit }}
runs:
  using: "composite"
  steps:
    - name: Get commit hash of current branch
      id: get_hash
      run: echo "hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
      shell: bash
    - name: Cache screenshots
      uses: actions/cache@v4
      id: expected_screenshots_cache_id
      with:
        path: __screenshots__
        key: ${{ runner.os }}-screenshots-${{ steps.get_hash.outputs.hash }}
```

mainブランチにチェックアウトして、スクショがキャッシュに保存されているかどうかを確認しています。  
キャッシュのキーはコミットハッシュにしているので、mainブランチに変更があれば、再度スクショを取ってキャッシュに保存しています。

#### キャッシュされたStorybookのビルド生成物の再利用

```yml:./.github/composite_actions/cache-storybook-static/action.yml
name: "Restore or Build Storybook"
description: "キャッシュに保存されているStorybookのビルド成果物を再利用する。(保存されていなかった場合はビルドする)"
runs:
  using: "composite"
  steps:
    - name: Get commit hash of current branch
      id: get_hash
      run: echo "hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
      shell: bash
    # キャッシュされたStorybookのビルド成果物を取得する
    - name: Restore cached storybook build result
      id: storybook_cache
      uses: actions/cache@v4
      with:
        path: storybook-static
        key: ${{ runner.os }}-storybook-static-${{ steps.get_hash.outputs.hash  }}
    # キャッシュが存在しなかったら、Storybookをビルドする(Job終了時にビルド成果物はキャッシュされます)
    - name: Build Storybook
      if: ${{ steps.storybook_cache.outputs.cache-hit != 'true'}}
      run: npm run build-storybook
      working-directory: client
      shell: bash
```

Storybookのビルドも時間がかかる処理なので、コミットハッシュが変わっていなかったら、キャッシュに保存したビルド生成物を再利用しています。

### ワークフロー実装 Job② featureブランチのスクショの準備

```yml:./.github/workflows/vrt.yml
take_actual_screenshots:
    name: Take Actual Screenshots
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - name: Check out code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Client setup
        uses: ./.github/composite_actions/client-setup

      - name: Workaround for detached HEAD
        run: |
          git checkout ${GITHUB_HEAD_REF#refs/heads/} || git checkout -b ${GITHUB_HEAD_REF#refs/heads/} && git pull

      - name: Build Storybook
        run: npm run build-storybook

      - name: Install Chrome for puppeteer
        run: npx puppeteer browsers install chrome

      - name: Take actual screenshots
        run: npm run storycap

      - name: Upload actual screenshots to artifact
        uses: actions/upload-artifact@v4
        with:
          name: actual-screenshots
          path: __screenshots__
          retention-days: 1
```

featureブランチにチェックアウトして、StorybookをビルドしてStorycapでスクショを撮っています。　　
撮影したfeatureブランチのスクショは、このPRのCIのみで使うので、キャッシュではなく[Artifacts](https://docs.github.com/ja/actions/using-workflows/storing-workflow-data-as-artifacts) に保存しています。

(Artifactsは同一ワークフロー内のみでデータを再利用可能です。)

### ワークフロー実装 Job③ main・featureブランチの画像比較して、レポート配信

```yml:./.github/workflows/vrt.yml
compare_screenshots:
    name: Compare Screenshots
    needs:
      - take_expected_screenshots
      - take_actual_screenshots
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - name: Check out code
        if: ${{ github.event_name != 'pull_request_target' }}
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Check out PR
        if: ${{ github.event_name == 'pull_request_target' }}
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          fetch-depth: 0

      - name: Client setup
        uses: ./.github/composite_actions/client-setup

      - name: Download expected screenshots from artifact
        uses: actions/download-artifact@v4
        with:
          name: expected-screenshots
          path: .reg/expected/

      - name: Download actual screenshots from artifact
        uses: actions/download-artifact@v4
        with:
          name: actual-screenshots
          path: __screenshots__

      - name: Workaround for detached HEAD
        run: git checkout ${GITHUB_HEAD_REF#refs/heads/} || git checkout -b ${GITHUB_HEAD_REF#refs/heads/} && git pull

      - name: Compare Screenshots
        run: npm run reg-suit

      - name: Deploy VRT report to github page
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: .reg/
          destination_dir: ${{ github.head_ref }}/vrt-report

      - name: Find comment
        uses: peter-evans/find-comment@v3
        id: find_comment
        with:
          issue-number: ${{ github.event.pull_request.number }}
          comment-author: "github-actions[bot]"
          body-includes: reg-suit report

      - name: Comment VRT report URL
        uses: peter-evans/create-or-update-comment@v4
        with:
          comment-id: ${{ steps.find_comment.outputs.comment-id }}
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            reg-suit report
            https://fictional-adventure-eveyz55.pages.github.io/${{github.head_ref}}/vrt-report
          edit-mode: replace
```

①・② でArtifacts にアップロードしたmain・featureブランチのスクショを、③のジョブでダウンロードしてreg-suit でVRT実行しています。

reg-suitのレポート(VRT結果) は、GitHub Pageにデプロイして、そのURLをbotがPRにコメントするようにしています。

## まとめ

GitHub にはキャッシュ・Artifacts・Page があるので、有効活用すれば他のサービスを使わずにVRTを運用できます。

## 参考文献

- [GitHub Actions のキャッシュを使った VRT のすゝめ](https://zenn.dev/cybozu_ept/articles/practice-vrt-using-github-actions-cache)
- [GitHub だけで VRT する仕組みを作ってみた](https://qiita.com/tacrew/items/f816c3690c0b5bc7bcb5?utm_source=pocket_saves)