GitHub Actionsで実行するstorycap / reg-suit の高速化

こんにちは!ROUTE06 プロダクトデベロップメント本部の @mh4gf です。
現在僕が関わっているプロジェクトでは、実装の変更に伴う UI のデグレードを検知するためにstorycapreg-suitを利用した Visual Regression Test を GitHub Actions で実施しています。
運用を進める中で撮影対象のスクリーンショット数も増え、テスト実行時間の増加に悩まされてきました。テスト高速化に取り組みいくつかの改善に成功したため、この記事でその方法を紹介します。

3 行まとめ

  • まず最初に取り組むべき並列実行の基本についてはこの記事を参照してください https://blog.wadackel.me/2022/vrt-performance-optimize/
  • turborepo を利用し storybook の差分ビルドを行う
  • GitHub Actions では Job ごとに依存パッケージのインストールが必要になるため、npx での実行やパッケージ分離などを利用し短縮化する

前提となる技術スタック

現在のプロジェクトで利用している技術スタックは以下です。

  • Next.js v13
  • Storybook v6 / Webpack
  • storycap v4.0.0
  • reg-suit v0.12.1
  • GitHub Actions

また yarn workspace を利用した monorepo によるパッケージ分離を行なっています。以下のようなディレクトリ構成となっています。

./
├── apps/
│   ├── app/
│   └── storybook/
├── packages/
│   └── some-package/
├── lib/
│   └── reg-suit/
└── package.json

app/ は Next.js アプリケーション、 storybook/ は storybook 関連の依存関係や設定をまとめています。 packages/ はいくつかのパッケージにモジュール分割してロジックを管理しています。
lib/ に含めている reg-suit/ には reg-suit とプラグインをまとめており、yarn workspace の管理外の独立したパッケージとしています。今回の高速化に関わるため後述します。


また本記事では storycap や reg-suit を始めいくつかのツールを利用した実例を紹介しますが、それぞれのツールの利用方法等は説明を省略しています。あらかじめご了承ください。

起きていた問題

高速化を実施する前は以下のような状況となっていました。

  • スクリーンショット対象の Story 数は 900 件
  • Play function を多用しており、レンダリング終了に時間がかかる Story がいくつかある
  • ワークフローの終了まで 30 分以上かかっていた

結果

以下が実際のプロジェクトでの Summary のスクリーンショットです。スクリーンショット撮影を 15 並列で実施し、結果として 11 分で完了するようになりました。
30 分以上かかっていたことを考えると 63%の削減が可能となりました。

チューニング後のGitHub Actionsでの実行結果

この計測時間は後述する turborepo による build フェーズのスキップは行なっていない場合の時間となるため、build フェーズがスキップできる状況であればさらに高速に実行できます。

storycap の並列実行

storycap / reg-suit を利用した VRT の高速化でまず取り組むべきことは storycap を複数マシンで並列実行することです。詳しくは両パッケージ作成者の @wadackel さんの記事によくまとまっています。

https://blog.wadackel.me/2022/vrt-performance-optimize/

上記記事の紹介だけではこの記事の価値がなくなってしまいますが、この記事では GitHub Actions 特有の改善内容を始めとした、追加で実施した改善内容について紹介します。

最終的なワークフローファイルの紹介

早速結論ですが、今回の最適化を実施したワークフローファイルを紹介します。
実際にプロジェクトで動かしているワークフローは monorepo のためもう少し複雑で、今回は簡略化したものとなります。

// .github/workflows/vrt.yml
name: vrt

on:
  pull_request:

jobs:
  storybook-build:
    runs-on: ubuntu-latest
    timeout-minutes: 15

    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v3

      - name: Cache turbo build setup
        uses: actions/cache@v3
        with:
          path: ./.turbo
          key: ${{ runner.os }}-turbo-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-turbo-

      - name: Setup node env
        uses: actions/setup-node@v3.5.1
        with:
          node-version-file: ".node-version"
          cache: "yarn"
          cache-dependency-path: "yarn.lock"

      - name: Install dependencies
        run: yarn install --frozen-lockfile --prefer-offline

      - name: build storybook
        run: yarn build --filter=storybook

      - name: Upload storybook artifact
        uses: actions/upload-artifact@v3
        with:
          name: storybook-artifact
          path: apps/storybook/storybook-static
          retention-days: 1

  storybook-screenshot:
    needs: storybook-build
    runs-on: ubuntu-latest
    timeout-minutes: 15

    permissions:
      contents: read

    strategy:
      matrix:
        shard: [1/15, 2/15, 3/15, 4/15, 5/15, 6/15, 7/15, 8/15, 9/15, 10/15, 11/15, 12/15, 13/15, 14/15, 15/15]

    steps:
      - uses: actions/checkout@v3

      - name: Setup node env
        uses: actions/setup-node@v3.5.1
        with:
          node-version-file: ".node-version"
          cache: 'npm'
          cache-dependency-path: '.github/workflows/vrt.yml'

      - name: Install native dependencies
        run: sudo apt-get install fonts-ipafont-gothic

      - name: Download storybook artifact
        uses: actions/download-artifact@v3
        with:
          name: storybook-artifact
          path: apps/storybook/storybook-static

      - name: run storycap
        run: npx storycap http://127.0.0.1:6006 --serverCmd 'npx http-server storybook-static --ci -p 6006' --shard=${{ matrix.shard }}

      - name: Upload storybook screenshots
        uses: actions/upload-artifact@v3
        with:
          name: storycap-artifact
          path: apps/storybook/__screenshots__
          retention-days: 1

  regression:
    needs: storybook-screenshot
    runs-on: ubuntu-latest
    timeout-minutes: 15

    permissions:
      id-token: write
      contents: read

    env:
      CI: true
      AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
      REG_NOTICE_CLIENT_ID: ${{ secrets.REG_NOTICE_CLIENT_ID }}

    defaults:
      run:
        working-directory: ./lib/reg-suit

    steps:
      - uses: actions/checkout@v3

      - name: Setup node env 🏗
        uses: actions/setup-node@v3.5.1
        with:
          node-version-file: ".node-version"
          cache: "yarn"
          cache-dependency-path: "lib/reg-suit/yarn.lock"

      - name: Install dependencies
        run: yarn install --frozen-lockfile --prefer-offline

      - name: Download storybook screenshots
        uses: actions/download-artifact@v3
        with:
          name: storycap-artifact
          path: apps/storybook/__screenshots__

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@master
        with:
          aws-region: ap-northeast-1
          role-to-assume: ${{ env.AWS_ROLE_ARN }}
          role-duration-seconds: 1800

      - name: run reg-suit
        run: yarn reg-suit run

フェーズごとに job を分離しています。

  • build: storybook の build, turborepo による差分ビルドを実施する。artifact に build 成果物をアップロードする
  • screenshot: artifact から build 成果物をダウンロードし serve、storycap を実行し、artifact にスクリーンショットファイルをアップロードする。shard オプションを使ってスクリーンショット対象を絞り込むことでこの job を並列化する。shard オプションは後述
  • regression: artifact からスクリーンショットファイルをダウンロードし reg-suit を実行

それぞれの job を詳しく紹介していきます。

build job

build-storybook を実行しビルドし、ビルド成果物を後続の job で利用するために Artifacts へアップロードします。

storybook-build:
  runs-on: ubuntu-latest
  timeout-minutes: 15

  permissions:
    contents: read # git checkoutのために必要

  steps:
    - uses: actions/checkout@v3

    # turborepoのキャッシュをGitHub Actionsで扱う
    - name: Cache turbo build setup
      uses: actions/cache@v3
      with:
        path: ./.turbo
        key: ${{ runner.os }}-turbo-${{ github.sha }}
        restore-keys: |
          ${{ runner.os }}-turbo-

    - name: Setup node env
      uses: actions/setup-node@v3.5.1
      with:
        node-version-file: ".node-version"
        cache: "yarn"
        cache-dependency-path: "yarn.lock"

    - name: Install dependencies
      run: yarn install --frozen-lockfile --prefer-offline

    # turborepoによる差分ビルドを実施する。詳しくは後述
    - name: build storybook
      run: yarn build --filter=storybook

    - name: Upload storybook artifact
      uses: actions/upload-artifact@v3
      with:
        name: storybook-artifact
        path: apps/storybook/storybook-static
        retention-days: 1

turborepo による差分ビルド

turborepoを利用し、storybook のビルドで何度も同じ成果物のビルドが行われてしまうことを避けています。
この設定は本質的な VRT の実行時間削減には貢献しませんが、例えば「アプリケーションコードや story ファイルの変更はないが storycap や reg-suit の設定を変えて実行したい」といった状況でビルドフェーズをスキップすることが可能になります。
また今回の記事では簡略化のために単に「storybook のビルド」としていますが、実際のプロジェクトではコードジェネレート等 storybook のビルドの前に必要なビルドステップがあることも多いです。それらのビルドを差分実行できるようにしておくと効率的なビルド実行が可能となります。

今回は turborepo の詳細な説明はしませんが、実際の設定例を紹介します。turbo.json は以下のようにしています。

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {}
  }
}
// apps/storybook/turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "extends": ["//"],
  "pipeline": {
    "build": {
      "outputs": ["storybook-static/**"],
      "inputs": ["../app/**/*.tsx"]
    }
  }
}

inputs に指定したファイルに変更があれば差分ビルドさせます。実際にはプロジェクトごとに追加で必要な依存があるはずですので、適宜調整が必要となります。

続いて npm scripts は以下となっています。

// package.json
"build": "turbo build --cache-dir=.turbo"
// apps/storybook/package.json
"build": "build-storybook",

前述の通り GitHub Actions での実行内容は yarn build --filter=storybook としており、storybook だけをビルドさせます。
ここでは --cache-dir を指定しディレクトリを変更しています。turborepo はデフォルトでは ./node_modules/.cache/turbo にキャッシュを保存しますが、GitHub Actions でキャッシュを扱うことを考えると node_modules は扱いづらいです。
そのため別ディレクトリに設定し GitHub Actions 上で actions/cache を利用しキャッシュを扱えるようにします。

https://turbo.build/repo/docs/reference/command-line-reference#--cache-dir

screenshot job

storycap を実行しスクリーンショットを撮る job です。

storybook-screenshot:
  needs: storybook-build
  runs-on: ubuntu-latest
  timeout-minutes: 15

permissions:
  contents: read # git checkoutで必要

strategy:
  matrix:
    shard: [1/15, 2/15, 3/15, 4/15, 5/15, 6/15, 7/15, 8/15, 9/15, 10/15, 11/15, 12/15, 13/15, 14/15, 15/15] # ジョブの並列数。状況によって増減させて良い
steps:
  - uses: actions/checkout@v3

  # 実行時間の削減のためyarn installを実施しない。詳しくは後述
  - name: Setup node env
    uses: actions/setup-node@v3.5.1
    with:
      node-version-file: ".node-version"
      cache: "npm"
      cache-dependency-path: ".github/workflows/vrt.yml"

  # スクリーンショット画像で豆腐になってしまうため日本語フォントをダウンロード
  - name: Install native dependencies
    run: sudo apt-get install fonts-ipafont-gothic

  # build jobの成果物をダウンロード
  - name: Download storybook artifact
    uses: actions/download-artifact@v3
    with:
      name: storybook-artifact
      path: apps/storybook/storybook-static

  # npm scriptsで実施せずstorycapで実行する
  # serverCmdを利用しビルド成果物をserveする
  # shardオプションを利用してスクリーンショット対象を絞り込む
  - name: run storycap
    run: npx storycap http://127.0.0.1:6006 --serverCmd 'npx http-server storybook-static --ci -p 6006' --shard=${{ matrix.shard }}

  # 撮影したスクリーンショットをartifactへアップロード
  - name: Upload storybook screenshots
    uses: actions/upload-artifact@v3
    with:
      name: storycap-artifact
      path: apps/storybook/__screenshots__
      retention-days: 1

ここで重要なポイントは以下の二つです。

  • npx で storycap を実行する
  • shard オプションを利用してスクリーンショット対象を絞り込む
  • artifact へのアップロードは並列するジョブ間で同じ name を指定しても問題ない

npx で storycap を実行する

GitHub Actions では Circle CI のWorkspace機能のようなジョブのセットアップ処理の共通化ができず、全ての job で git checkout と yarn install を行う必要があります。今回のプロジェクトは比較的大規模となっており、setup-node のキャッシュを利用したとしても yarn install で 1 分程度かかっていました。
job の分離により各ジョブで yarn install が行われることになるため、job 全体の体感時間としては build → screenshot → regression で yarn install に 3 分かかることになります。また screenshot job を 15 並列で実施すると billable time(課金対象時間)としては 15 分かかってしまいます。

解決策としては storycap を npx で単体実行することで yarn install をスキップすることができます。build job で生成した storybook-static ディレクトリがあるため、storycap は他のパッケージの依存なく単体で実行することが可能です。

注意点としては、npx で実行するものの package のインストールは依然として必要な点です。managed mode で storycap を利用するためには storybook に addon と decorator を追加する必要があるためです。storycap の利用方法についてはこちらをご覧ください。

https://github.com/reg-viz/storycap/tree/v4.0.0#managed-mode


また、npx で実行するパッケージを都度レジストリからダウンロードするのはよろしくないため、キャッシュする方法として setup-node の cache-dependency-path に workflow ファイルを指定するテクニックも有用です。
これにより workflow ファイルの変更によってキャッシュが破棄されます。

https://til.simonwillison.net/github-actions/npm-cache-with-npx-no-package

shard オプションを利用してスクリーンショット対象を絞り込む

元記事ではスクリーンショット対象の絞り込みのためにシェルスクリプトを用意することで解決していました。
しかし storycap v4.0.0 ではシャーディングを実現するための --shard オプションが追加されました。これは Jest や Playwright と同様に 1/2 のようなフォーマットで絞り込みを行うことができます。
そのため絞り込みのためのスクリプトを用意する必要なくマシンレベルでの job 分割が可能となりました。

artifact へのアップロードは並列するジョブ間で同じ name を指定しても問題ない

screenshot job は並列で実行し、撮影したスクリーンショットを artifact へ保存します。ここで懸念としてあったのが並列したジョブ間で同じ artifact の保存が可能なのか?コンフリクトが発生することはないのか?という点でした。
結論から言うと特に問題はありませんでした。それぞれの job でアップロードするファイル群はそれぞれマージされて保存されるようです。job 間で同名ファイルパスでのアップロードがあるならば話は別ですが、シャーディングされた job で同名ファイルが生成されることはないので大丈夫なようです。

regression job

最後に撮影したスクリーンショットを利用して reg-suit による比較を行います。

regression:
  needs: storybook-screenshot
  runs-on: ubuntu-latest
  timeout-minutes: 15

  permissions:
    id-token: write # aws-actions/configure-aws-credentialsで利用
    contents: read # git checkoutで利用

  env:
    CI: true
    AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
    REG_NOTICE_CLIENT_ID: ${{ secrets.REG_NOTICE_CLIENT_ID }}

  defaults:
    run:
      working-directory: ./lib/reg-suit # yarn workspace 管理外で実行するため working directory を変更する

  steps:
    - uses: actions/checkout@v3

    - name: Setup node env 🏗
      uses: actions/setup-node@v3.5.1
      with:
        node-version-file: ".node-version"
        cache: "yarn"
        cache-dependency-path: "lib/reg-suit/yarn.lock"

    - name: Install dependencies
      run: yarn install --frozen-lockfile --prefer-offline

    # 撮影したスクリーンショットをダウンロード
    - name: Download storybook screenshots
      uses: actions/download-artifact@v3
      with:
        name: storycap-artifact
        path: apps/storybook/__screenshots__

    # reg-publish-s3-pluginでS3へアップロードするためのAWS認証情報
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@master
      with:
        aws-region: ap-northeast-1
        role-to-assume: ${{ env.AWS_ROLE_ARN }}
        role-duration-seconds: 1800

    - name: run reg-suit
      run: yarn reg-suit run

ここでは working-directory を ./lib/reg-suit/ へ変更して実行しています。screenshot job と同様に必要な依存だけをインストールすることによる高速化を目的としています。
storycap は他のパッケージへの依存がないため npx で単体実行できましたが、reg-suit の場合いくつかのプラグインに依存しかつ実行時に require するため npx で実行できません。そのため package.json を分離し別プロジェクトとして独立して実行することにより実現できました。

余談:Yarn v1 の nohoist について

package.json の分離という解決方法は初見では驚きがあるかもしれません。本来はワークスペース内のパッケージとして含められたらより良いと思っていました。
reg-suit の依存解決高速化で行いたいことは「ワークスペース全体の依存を解決するのではなくパッケージの実行に必要な依存だけ解決したい」であり、これは実は Yarn v1 の nohoist オプションを利用することでプロジェクトルートの node_modules から分離することが可能です。

https://classic.yarnpkg.com/blog/2018/02/15/nohoist/

ただ、この nohoist オプションは Yarn v2 以降ではサポートされていないのです。また pnpm では同等の機能はないようです。( 近しい機能として shamefully-hoist オプションがありますが、これはプロジェクト全体に適用されてしまうため特定のパッケージだけ分離することはできません) 現在僕たちのチームでは Yarn v1 からの別のパッケージマネージャへの移行を検討しているということもあり、どのパッケージマネージャでも対応できる方法にするために別プロジェクトとして分離する方法を選択しました。

また、元記事では reg-suit run を使わずに sync-expected を job として分離する方法も紹介されていましたが、現状 reg-suit run の実行は 1 分程度で完了するため効果が薄く実施していません。

終わりに

本記事では GitHub Actions で storycap / reg-suit を実施する上での高速化について紹介しました。前述した通り当初の 63%の削減ができました。
残っている改善できそうな項目として、現在最も時間がかかっているのは Storybook のビルド時間となっています。
Webpack を別のモジュールバンドラに切り替えたり、storybook のビルドプロセス自体を分割する手もあるかもしれません。こちらについては実行時間の課題がまた顕著になってきたら取り組もうと思っています。
他の最適化の方法をご存知の方がいればぜひお知らせください。

また、ROUTE06 では今回のような Web フロントエンドテストの改善に一緒に取り組んでいただけるメンバーを募集しています!興味のある方はこちらからご連絡ください。

2023/05/31追記

ROUTE06のメンバーがお送りする「ルートシックスラジオ」で、プロジェクトのチームメンバーとこの記事の内容について話しました。 改善に取り組む経緯や、Twitter上で頂いた質問にもお答えしていますので、ぜひ聴いてみてください!