🏎️

VRTの実行時間を短縮した話

2025/02/13に公開

こんにちは!アルダグラムでエンジニアをしている秋田です。

弊社では以前、 フロントエンドのライブラリ134個を一気にアップデートしてリリース しました。今回はその中で起きたVRT(Visual Regression Testing)の課題と対応について紹介したいと思います。

前提

弊社サービス KANNA のフロントエンド開発にはVRTを導入しています。

使用技術としては Storybook + storycap + reg-suit で、GitHubへのpushをトリガーにして GitHub Actions のワークフローとして自動実行します。実行結果は Amazon S3 に保存され、UIの変更差分が確認可能です。便利ですね。

ところがフロントエンドのライブラリアップデートを実施した際に、2つの課題が発生しました。

起きたこと

VRTの実行時間が長くなった

アップデート前は15分程度で終わっていたところが、アップデート後は18分以上かかるようになりました。20%以上も増加していたので、これは結構厳しいですね。

実行時間の増加により待ち時間が長くなるというのもありますが、GitHub Actions で動かしているのでコスト的にも良くないです。

処理完了後もプロセスが終了しなくなった

必ず起きるわけではないですが、頻繁に以下のエラーが出るようになりました。

error Navigation timeout of 30000 ms exceeded
TimeoutError: Navigation timeout of 30000 ms exceeded

これが発生した場合、高確率でstorycapが終了しなくなりました。

スクリーンショットの取得自体は成功しているにも関わらず、プロセスが終了しない状態です。この状態になると、GitHub Actions ワークフローの該当ステップで止まってしまいます。事象の検知当初は、余裕を持ったタイムアウトを設定した上で、エラーでも続行するように仮対応していました。

タイムアウトによる設定でもVRTが実行できない問題は回避可能でしたが、

  • タイムアウトまで待機するため、無駄な待ち時間が発生する
  • タイムアウトの設定を短くしすぎると、スクリーンショットの取得が完了する前にプロセスが終了してしまう
  • テスト項目の追加に応じてタイムアウト設定の再調整が必要

という別の問題が残ります。

調査と検証

実行時間の増加およびstorycapのプロセスが終了しない問題に関して、調査と検証をしました。

最終的に両方の根本原因は特定できませんでしたが、以下の可能性が考えられます。

  • ライブラリアップデートにより何らかの処理が増えた
  • ライブラリアップデートにより何らかのタイミングが変わった
  • メモリリークや負荷の増加

storycapのプロセスに関しては 実行するスクリーンショットの数を減らすと起きなくなる ということが確認できました。ただしローカルの作業PCではそもそも事象が再現せず、GitHub Actions のランナーでしか発生しないのが厄介でした。

ということで、この調査だけに時間をかけるのも難しかったので、判明した事実を元に対策を実施することにしました。

対策

並列化

よくある対応ですが、並列化を実施しました。

VRTに関連するジョブを

  • storybookによるビルド
  • storycapによるスクリーンショット
  • reg-suitによる差分検出

の3つに分割し、スクリーンショット部分のみ並列で動かします。

GitHub Actions の strategy.matrix でジョブを並列化しますが、ここで分割の仕方について検討しました。

GitHub がホストしているランナーで実行されるジョブは分単位でコストがかかるため、あまりに分割しすぎると端数にコストパフォーマンスが悪くなります。さらに、ジョブを分割するとそれぞれのジョブに初期化処理を入れる必要があり、全体的な処理内容としては増加してしまいます。

逆に分割数を減らすと時間短縮効果が落ちますし、storycapのプロセス問題が解決できません。また、同ワークフローではVRT以外にもテストなどが並列実行されており、VRTだけ短くしてもワークフローの実行時間は短くならないので効果が薄いです。

上記を踏まえ何回か測定した結果、以下が分かりました。

  • storycapのプロセス問題は、現状のスクリーンショット数で8分割ぐらいだと解消される
  • 時間短縮で考えると、3分割で十分効果が出る

ここから考えると、実行ステップとしては8分割以上が必須、ただし並列数は3つぐらいが良さそうです。当初は単純に並列化を考えていましたが、一部直列で分割したほうが良いと判断しました。

最終的に、ワークフローは以下のような形になりました(一部抜粋)。

jobs:
  vrt-build:
    steps:
      # 各種セットアップステップ(省略)
      # storybook build ステップ(省略)
      # 成果物の upload-artifact ステップ(省略)

  vrt-screenshot:
    needs: vrt-build
    strategy:
      matrix:
        include:
          - shard1: 1/9
            shard2: 2/9
            shard3: 3/9
          - shard1: 4/9
            shard2: 5/9
            shard3: 6/9
          - shard1: 7/9
            shard2: 8/9
            shard3: 9/9
    steps:
      # 各種セットアップステップ(省略)
      # ビルド成果物の download-artifact ステップ(省略)

      - name: create the current snapshot shard1
        run: |
          npm run storycap -- (パラメータなど省略) --shard=${{ matrix.shard1 }}

      - name: create the current snapshot shard2
        run: |
          npm run storycap -- (パラメータなど省略) --shard=${{ matrix.shard2 }}

      - name: create the current snapshot shard3
        run: |
          npm run storycap -- (パラメータなど省略) --shard=${{ matrix.shard3 }}

      # スクリーンショットの upload-artifact ステップ(省略)

  vrt-reg:
    needs: vrt-screenshot
    steps:
      # 各種セットアップステップ(省略)
      # スクリーンショットの download-artifact ステップ(省略)
      # reg-suit 実行ステップ(省略)

今回の実装では分割数が固定となっているため、将来的に変更する際には各stepsの調整が必要になる点は微妙なところです。特に直列で実行する部分に関してはどう書いてもイマイチ加減が大差なかったので、素直にコピペしました。

なお、このワークフローとは別に 対応言語ごとにVRTを実行する というワークフローもあり、そちらも並列化対応しました。strategyには 個別の配列とincludeを併用すると意図通り動かない問題 が本件対応時点で存在しており、さすがにベタでmatrixを書くのはつらかったので、以下のようにしています(こちらも一部抜粋)。

jobs:
  setting:
    env:
      VRT_LOCALES: '["ja", "th", (...他言語省略)]' # VRT対象言語
      VRT_PARALLELS: 3 # 言語ごとのvrt-screenshotの並列実行数
      VRT_STEP_SHARD: 3 # vrt-screenshot内のstorycap分割数
    outputs:
      VRT_SCREENSHOT_MATRIX: ${{ steps.vrt_screenshot_matrix.outputs.vrt_screenshot_matrix }}
    steps:
      # VRTスクリーンショット並列実行でlocalesとincludeを併用できないので、事前にmatrixのjsonを生成する
      - name: Generate vrt screenshot matrix
        id: vrt_screenshot_matrix
        run: |-
          ruby -e '
            require "json"
            col_size=ENV["VRT_STEP_SHARD"].to_i
            row_size=ENV["VRT_PARALLELS"].to_i
            print "vrt_screenshot_matrix=", JSON[
              JSON.parse(ENV["VRT_LOCALES"]).map{|locale|
                (0..row_size-1).map{|row|
                  (1..col_size).map{|col|
                    ["shard#{col}", "#{col+row*col_size}/#{col_size*row_size}"]
                  }.to_h.merge({locales: locale})
                }
              }.flatten
            ]
          ' >> "$GITHUB_OUTPUT"

  storybook:
    needs: [setting]
    strategy:
      matrix:
        locales: ${{ fromJson(needs.setting.outputs.VRT_LOCALES) }}
    steps:
      # storybook build 関連(省略)

  vrt-screenshot:
    needs: [setting, storybook]
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include: ${{ fromJson(needs.setting.outputs.VRT_SCREENSHOT_MATRIX) }}
    steps:
      # 各種セットアップステップ(省略)
      # ビルド成果物の download-artifact ステップ(省略)

      - name: create the current snapshot shard1
        run: |
          npm run storycap -- (パラメータなど省略) --outDir="__screenshots__/${{ matrix.locales }}" --shard=${{ matrix.shard1 }}
      # 以下略

Rubyでmatrix用のJSON文字列を生成し、必要なstrategy箇所でincludeしています。ちょっと苦しいですね。

Rubyで出力しているJSON文字列は以下のような内容なので、これができればRuby以外でもいいと思います。今回は別の箇所でRubyを実行している箇所があったので、Rubyを選択しました。

[
  {"shard1":"1/9","shard2":"2/9","shard3":"3/9","locales":"ja"},
  {"shard1":"4/9","shard2":"5/9","shard3":"6/9","locales":"ja"},
  {"shard1":"7/9","shard2":"8/9","shard3":"9/9","locales":"ja"},
  {"shard1":"1/9","shard2":"2/9","shard3":"3/9","locales":"th"},
  // 以下略

※見やすくするために整形していますが、実際にはインデント及び改行なし

オプション追加

実行時間についてはもう一つ、非常に効果が出た対策があります。

Storybook 8でbuildに追加された --test オプションです。

https://storybook.js.org/blog/storybook-8/#2-4x-faster-test-builds

このオプションはテスト用途において不要なドキュメント生成などの処理を省略するため、従来のビルドよりも大幅に高速なビルドが可能となります。

storybook build --test

やることとしてはたったこれだけなのですが、弊社環境で計測したところセットアップを含むビルド時間が

4分31秒 → 1分33秒

と、3分程度短縮されました。短縮後の storybook build 自体は50秒ほどで終わっていたので、“2-4x faster test builds” というのも納得です。

結果

ライブラリアップデート前


この時点でvrtジョブに15分25秒と、最も時間がかかっています。

test-runnerジョブはStorybookを使用した言語別のテストで、主な実行コマンドとしては storybook buildtest-storybook になります。

ライブラリアップデート後


ライブラリアップデート後にvrtジョブの実行時間が18分19秒と長くなってしまった状態です。

元々vrtに最も時間がかかっていたので、全体の実行完了も長くなっています。それ以外はそこまで大差ありません。

vrtジョブはstorycap実行のステップでタイムアウトさせていて、設定は12分になっています。

対策実施後


vrtジョブを分割したので分かりにくいですが、VRT関連の完了時間および全体の実行時間が短縮できているのが確認できます。対策実施前と比較して18分→9分と、実行時間を50%も短縮できました。またtest-runnerジョブについても storybook build に —test オプションを追加したため短縮されています。Billable time も少なくなっており、コスト的にも有利になっているのがいいですね。

ただし、よく見るとVRT関連のジョブ(vrt-build, vrt-screenshot, vrt-reg)に関しては合計処理時間が増えており、分単位で計算すると

2 + (6 + 5 + 5) + 2 = 20

となり、20分です。ライブラリアップデート前が16分、ライブラリアップデート後でも19分であることを考えると、並列化によるコスト増は見逃せないポイントではないかと思います。

最後に

処理時間の短縮は実質オプション追加によるもののみではあったのですが、非常に効果が高かったです。Storybook 8系を使用していて未検討なのであれば、一度検討してみることをお勧めします。また稀ではあると思いますが、storycapで同様の事象が出た場合、本記事のように1プロセスのスクリーンショット数を減らす対応が有効かもしれません。

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion