GitHubだけでVRTする仕組みを作ってみた

この記事は[ファンタアドベントカレンダー2023](https://qiita.com/advent-calendar/2023/funteractive-inc)の2日目の記事です！

社内で今までVRT（Visual Regression Test）の採用経験がなかったため、VRT環境を一度試してみよう！という話になりました。この際、以下の2点を踏まえてGitHubだけで完結するVRT環境を構築しました。
- S3などのストレージが必要ない手軽なインフラ環境で試したい
- 既存の財産・知見であるStorybookを活用したい

本記事では、その環境構築手順と気づきを共有します。
なお本記事で掲載した一部コードを含む全体を[レポジトリで公開](https://github.com/tacrew/vrt-with-github-sample)しています。

:::note info
[Cybozu Frontend Advent Calendar 2023](https://adventar.org/calendars/9255)の12日目に[korosuke613さん](https://zenn.dev/korosuke613)も近い内容の記事を公開しています！
[GitHub Actions のキャッシュを使った VRT のすゝめ](https://zenn.dev/cybozu_ept/articles/practice-vrt-using-github-actions-cache)

スナップショットをどこに置くかの選択肢およびメリデメ等、本記事でカバーしていない内容も記載されています。
ぜひ参考にしてください！

:::

## 想定読者
- VRTに興味はあるが、試したことがない方
- 普段からStorybookを利用して開発をしている方

## 技術スタック
本記事で使用する主な技術スタックは以下の通りです。
- Storybook v7
- [storycap](https://github.com/reg-viz/storycap) v4.2.0: Storybookのスナップショットの生成
- [reg-suit](https://github.com/reg-viz/reg-suit) v0.12.2: 画像比較および差分レポートの生成
- GitHub Actions
- GitHub Pages

## VRTのフロー
VRTはGitHub Actionsを利用して実行されます。実行フローを下図に示します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/567173/37175972-0d53-70c5-8d97-fc7d3e8bc803.png)

### 1. 比較用画像群の生成
storycapを利用して参照スナップショット（develop/mainブランチ）とテスト対象スナップショット（featureブランチ）の画像群を生成します。
生成した画像群はArtifactsに保存され、VRT実行時に利用します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/567173/d4a9682c-6e1f-ba3b-73c6-7645824136fe.png)

### 2. VRT実行および結果レポート生成
reg-suitを利用してVRTを実行し、その結果をGitHub Pagesに公開します。また、PRコメントに結果サマリーとレポートのリンクを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/567173/8b17f61f-6d11-2ff4-a9fe-b70f38fde5a7.png)

### 3. レポートの閲覧
PRコメントに記載されたリンクからレポートを確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/567173/892aed6f-4456-0f6f-1da0-6379f162f23c.png)

## ワークフローの実装
以下では前項で紹介したフローの実装例を抜粋して解説します。
全体は[サンプルレポジトリ](https://github.com/tacrew/vrt-with-github-sample/blob/main/.github/workflows/visual-regression-test.yml)で公開していますので、よければ参考にしてください。

### 1. 比較用画像群の生成
参照スナップショットとテスト対象スナップショットの生成処理は独立しているため、並列で実行しています。
これらスナップショットは画像生成処理を実行するブランチが異なるだけで処理自体は同一となるため、以下では参照スナップショットを説明します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/567173/c3f89064-8af3-1871-20f9-4d0a460ff782.png)

画像生成処理は対象が増えるに従い実行時間が長くなるため、並列実行できるようにしています。そのため画像生成処理に利用するStorybookのbuild処理を別ジョブに切り出し、事前実行させています。

#### Storybookの事前buildジョブ
PR修正の度にスナップショットを生成するのはもったいないので、生成成功時にcacheさせています。
cacheが存在しない場合、Storybookのbuildを行います。

```yml
  check_expected_screenshots:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - uses: volta-cli/action@v4
      - uses: actions/checkout@v3
        with:
          ref: main
          fetch-depth: 0

      - name: restore cached expected screenshots
        id: screenshots_cache
        uses: ./.github/actions/cache-screenshots
        with:
          lookup-only: 'true'

      - name: restore cached node_modules
        if: ${{ steps.screenshots_cache.outputs.cache-hit != 'true' }}
        uses: ./.github/actions/cache-node-modules

      - name: cache storybook build result
        if: ${{ steps.screenshots_cache.outputs.cache-hit != 'true' }}
        id: storybook_result_cache
        uses: ./.github/actions/cache-storybook-build-result

      - name: storybook build
        if: ${{ steps.screenshots_cache.outputs.cache-hit != 'true' && steps.storybook_result_cache.outputs.cache-hit != 'true' }}
        run: yarn storybook:build
        env:
          NEXT_IS_TEST: 'true'
```

#### スナップショット生成ジョブ
前述したように並列で実行します。各子ジョブで生成した画像群をArtifactsにアップロードして画像群を集約させます。

```yml
  take_expected_screenshots:
    needs: check_expected_screenshots
    runs-on: ubuntu-latest
    timeout-minutes: 30
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4] # 任意の並列数

    steps:
      - uses: volta-cli/action@v4
      - uses: actions/checkout@v3
        with:
          ref: main
          fetch-depth: 0

      - name: restore cached expected screenshots
        id: screenshots_cache
        uses: ./.github/actions/cache-screenshots
        with:
          lookup-only: 'false'

      - name: restore cached node_modules
        if: ${{ steps.screenshots_cache.outputs.cache-hit != 'true' }}
        uses: ./.github/actions/cache-node-modules

      - name: cache storybook build result
        if: ${{ steps.screenshots_cache.outputs.cache-hit != 'true' }}
        id: storybook_result_cache
        uses: ./.github/actions/cache-storybook-build-result

      - name: prepare for screenshots
        if: ${{ steps.screenshots_cache.outputs.cache-hit != 'true' }}
        uses: ./.github/actions/prepare-screenshots

      - name: take screenshots
        if: ${{ steps.screenshots_cache.outputs.cache-hit != 'true' }}
        run: yarn storybook:screenshot --shard=${{ matrix.shard }}

      - name: upload screenshots
        uses: actions/upload-artifact@v3
        with:
          name: expected-screenshots
          path: __screenshots__
          retention-days: 1
  ```

### 2. VRT実行および結果レポート生成
参照スナップショットとテスト対象スナップショットの生成ジョブが成功した場合、VRTを実行します。
実行後に予め設定したGitHub Pagesの公開元ブランチ`gh-pages`ブランチにレポートをpushします。
<details><summary>GitHub Pagesの事前設定内容</summary>

予め公開元ブランチを用意した上、`Settings > Pages` にて以下のような設定をしてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/567173/05dd6341-4436-2f05-6739-e03ea46a407e.png)

また、`Settings > Actions > General` にてワークフローの権限をread/writeに変更してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/567173/98d46916-cf53-fcf3-ebe2-152111a1132f.png)
</details>

```yml
  run_reg_suit:
    needs:
      - take_expected_screenshots
      - take_actual_screenshots
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - uses: volta-cli/action@v4
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0

        # cacheから参照スナップショットを取得
      - name: restore cached expected screenshots
        id: screenshots_cache
        uses: ./.github/actions/cache-screenshots
        with:
          lookup-only: 'false'

        # cacheにない場合はArtifactsから参照スナップショットを取得
      - name: download expected screenshots
        if: ${{ steps.screenshots_cache.outputs.cache-hit != 'true' }}
        uses: actions/download-artifact@v3
        with:
          name: expected-screenshots
          path: __screenshots__

        # 取得した参照スナップショットを参照用画像群に移動
      - name: set reg-suit expected
        if: ${{ steps.screenshots_cache.outputs.cache-hit != 'true' }}
        run: |
          rm -rf .reg/expected/
          mkdir -p .reg/expected
          mv -f __screenshots__/* .reg/expected/
          ls -l .reg/

        # Artifactsからテスト対象スナップショットを取得
      - name: download actual screenshots
        uses: actions/download-artifact@v3
        with:
          name: actual-screenshots
          path: __screenshots__

      - name: restore cached node_modules
        if: ${{ steps.screenshots_cache.outputs.cache-hit != 'true' }}
        uses: ./.github/actions/cache-node-modules

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

        # VRT実行
      - name: run reg-suit
        run: yarn test:vrt

        # GitHub Page公開元ブランチへレポートをpush
      - name: deploy report to github page
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: .reg/
          destination_dir: ${{ github.head_ref  }} # PR毎にディレクトリを分けることで上書きを防ぐ

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

      - name: upsert comment
        uses: peter-evans/create-or-update-comment@v3
        with:
          comment-id: ${{ steps.fc.outputs.comment-id }}
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            reg-suit report
            https://tacrew.github.io/vrt-with-github-sample/${{github.head_ref}}
          edit-mode: replace
```

## おわりに
本記事ではGitHubのみを利用してVRTを実行し、その結果を閲覧できる環境の構築方法を紹介しました。
VRTをちょっと試してみたいけどS3等を用意するのが手間...と感じていた方への朗報となれば幸いです！


:::note info
冒頭にも追記しましたが、[Cybozu Frontend Advent Calendar 2023](https://adventar.org/calendars/9255)の12日目に[korosuke613さん](https://zenn.dev/korosuke613)も近い内容の記事を公開しています！
[GitHub Actions のキャッシュを使った VRT のすゝめ](https://zenn.dev/cybozu_ept/articles/practice-vrt-using-github-actions-cache)

スナップショットをどこに置くかの選択肢およびメリデメ等、本記事でカバーしていない内容も記載されています。
ぜひ参考にしてください！

:::

明日はOSS活動など社内外で活躍しているエンジニア、[yoshi2no55](https://qiita.com/yoshi2no55)さんがNext.jsのMiddlewareを利用したIP制限のかけ方について記事を書いてくれます！乞うご期待

## おまけ
PRを閉じた後のVRT実行結果レポートをGitHub Pagesから削除するためのワークフローも紹介します。必要に応じて使用してください。

<details><summary>レポート削除ワークフロー</summary>

```yml:clean-vrt-report.yml
name: clean-vrt-report-branch
on:
  pull_request:
    types: [closed]

defaults:
  run:
    shell: bash

jobs:
  delete-dir:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          ref: gh-pages
          fetch-depth: 0

      - name: delete closed pr branch directory
        run: |
          if [[ "${{ github.head_ref }}" != "" ]]; then
            rm -rf "./${{ github.head_ref }}"
          fi

      - name: check if there were changes
        id: git_diff
        run: |
          git diff --exit-code || echo "changed=true" >> $GITHUB_OUTPUT

      - name: Commit and push
        if: steps.git_diff.outputs.changed == 'true'
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git add -A
          git commit -m "delete ${{ github.head_ref }} directory"
          git push
```
</details>


## 環境構築の際に参考にした記事
- [reg-suitをS3もGCPも使わずにGitHub ActionsでCIする](https://zenn.dev/mimokmt/articles/16b0d30e8cd87f)
- [GitHub Actionsで実行するstorycap / reg-suit の高速化](https://tech.route06.co.jp/entry/2023/03/29/080000)