Lost PixelとGithub Actionsを用いたVRTのユースケース

Published on April 21, 2024

撮影したスナップショットをGithub上で管理し、PR上で差分検知を行うことで、意図せぬビジュアルのデグレを防いでくれるLost Pixelの具体的な運用方法についてです

Table of Contents

Table of Contents

はじめに

2 月に開催された#vrt4選という勉強会に参加した際、その中で紹介されていた Lost Pixel というツールに興味を持ちました。

👇該当のスライド

普段は専らChromaticユーザなのですが、個人開発の際、無料枠の上限にヒットして痛い目にあった過去があるので、今回個人ブログでは Lost Pixel で VRT を行うことにしてみました💸

#vrt4選では詳細な実装や VRT の一連の流れまで述べられていなかったかつ、Lost Pixel はまだあまり普及しておらず、発表者の方のスライド以外で使用例があまり見つからなかったため、今回は Lost Pixel を用いた具体的な VRT の運用方法をまとめてみました。

前提

前提として、Next.js 製のブログアプリを含むモノリポ構成でできている、このブログアプリの/apps/blogマイクロサービスに VRT を施していきます。

ブログアプリの全体像は次の通りです。

blog.sakupi01.com
blog.sakupi01.com favicon blog.sakupi01.com

Lost Pixelとは

Lost Pixel とは、VRT(Visual Regression Test, ビジュアル回帰テスト)を行うためのツールです。

Lost Pixel - holistic Visual Regression Testing cloud
Easy to integrate and reliable visual regression testing cloud. Sleep better at night while shipping features.
Lost Pixel - holistic Visual Regression Testing cloud favicon www.lost-pixel.com
Lost Pixel - holistic Visual Regression Testing cloud

簡単に、VRT とは、コードベースの変更の前後でビジュアル的な(ここでは Web UI に対して)意図せぬ変化が生じていないかを検知するテストです。

VRT は Unit テストや Integration テストを代替するものではなく、これらのテストに加えて、人間が目視で認識しにくい部分のデグレでも、機械的に自動で見た目の正当性を担保するためのテストです。

VRT に関するより詳細な説明は以下を参照ください。

Lost Pixel Blog - Visual regression testing 101
Your entry point into the Visual Regression Testing world
Lost Pixel Blog - Visual regression testing 101 favicon www.lost-pixel.com
Lost Pixel Blog - Visual regression testing 101

VRT を実現するためのツールは、Chromatic, storycap でのスナップショット+reg-suit での差分比較などが挙げられますが、Lost Pixel の OSS モードを使用すると、私がこれまで使用した範囲では次のような利点がありました。

⭐️メリット

重複した説明が発生するため、ここでは簡単な説明に止めて詳細はスライドZennの記事に譲りたいと思います。

セットアップ

ページ単位のスクリーンショットのため、次のドキュメントの Page shots 部分を参照します。

Modes | Lost Pixel
Modes | Lost Pixel favicon docs.lost-pixel.com
Modes | Lost Pixel

ベースは上記の通りですが、今回はモノリポかつ CI の使用ということを加味して、次のように変更を加えています。

import { CustomProjectConfig } from "lost-pixel";

export const config: CustomProjectConfig = {
  pageShots: {
    pages: [
      { path: "/", name: "home" },
      { path: "/about", name: "about" },
      { path: "/dev", name: "dev" },
      { path: "/dev/articles/blog-tech-stack", name: "blog-tech-stack" },
      { path: "/dev/tag/react", name: "tag-react" },
      {
        path: "/life", // 🟢スクリーンショットを撮るページのpath
        name: "life", // 🟢スクリーンショットの名前
        threshold: 0.004, // 🟢閾値。どのくらいのズレまで許容するか
        mask: [{ selector: ".eye-catch" }], // 🟢マスクをかけるセレクタ
      },
      {
        path: "/life/articles/intern-completed-aritcle",
        name: "intern-completed-article",
        mask: [{ selector: ".thumbnail" }],
      },
      {
        path: "/life/tag/poem",
        name: "poem",
        threshold: 0.006,
        mask: [{ selector: ".eye-catch" }],
      },
    ],
    // 🟢ローカルで実行するときのIPはlocalhostで、Github Actionで実行するときのIPは172.17.0.1に設定する
    baseUrl: process.env.LOCAL
      ? "http://localhost:3000"
      : "http://172.17.0.1:3000",
  },
  waitBeforeScreenshot: 5000,// 🟢
  timeouts: {
    loadState: 50000, // 🟢
    networkRequests: 50000, // 🟢
  },
  
  // OSS mode
  // 🟢モノリポの場合、Github Actionで実行するときのパスはプロジェクトのrootからのパスを設定する
  imagePathBaseline: process.env.LOCAL // 🟢baselineイメージの格納先。ここに格納されているスクリーンショットが比較基準
    ? "./tests/vrt/baseline-images"
    : "./apps/blog.sakupi01.com/tests/vrt/baseline-images",
  imagePathCurrent: process.env.LOCAL // 🟢currentイメージの格納先。現状のスクリーンのキャプチャで、baselineと比較される
    ? "./tests/vrt/current-images"
    : "./apps/blog.sakupi01.com/tests/vrt/current-images",
  imagePathDifference: process.env.LOCAL // 🟢differenceイメージの格納先。currentImageとbaselineImageの差分画像
    ? "./tests/vrt/difference-images"
    : "./apps/blog.sakupi01.com/tests/vrt/difference-images",
  generateOnly: true,

  // 🟢baselineとcurrentに差分がある時は実行結果をfailとしてマーク
  failOnDifference: true,
};

プロジェクトに適用

運用シナリオ

早速、プロジェクトに Lost Pixel を適用していく手順を見ていきます👀

まず、今回の運用シナリオは以下とすることにします。

おおまかな流れを掴んだところで、実際に手順を確認していきましょう🏋🏻

ローカルで確認

0. ローカルで差分確認する

package.jsonに次のコマンドを追加します。 セットアップで設定したlostpixel.config.tsの内容をよく見ると、環境変数の値によって設定値を変えている部分があるので、そこを加味したスクリプトにします。

{
    ...
    "scripts": {
        ...,
        "lost-pixel": "LOCAL=true lost-pixel",
        "lost-pixel:update": "LOCAL=true lost-pixel update",
        ...
    }
    ...
}

上記のようにLOCAL=trueとしているのは、npm scripts を使用して実行するのはローカルだけで、CI ではlost-pixelのアクションを使用するので、この scripts の LOCAL 環境変数はtrueに設定しておいて問題ないです。

次のコマンドで、手元の環境で差分を確認してみると、わたしのプロジェクトでは次のようにスナップショットが出力されます。

bun lost-pixel

lost-pixel初回実行 lost-pixel初回実行の出力

1. baselineをupdateする

ところが、初めて実行すると、次のように、「baseline のスクショがないから比較できないよ〜」の例外がスローされます。

Screenshots done!
Creating shots took 20.902 seconds
🔍 Checking differences
Comparing 8 screenshots using 'pixelmatch' as compare engine
[1/8] Comparing 'home' (home)
[1/8] Baseline image missing. Will be treated as addition. (home)
[2/8] Comparing 'about' (about)
[2/8] Baseline image missing. Will be treated as addition. (about)
[3/8] Comparing 'dev' (dev)
[3/8] Baseline image missing. Will be treated as addition. (dev)
...
Comparison done!
👋 Exiting process with 0 found differences & 8 baselines to update
Sending anonymized telemetry data.
error: script "lost-pixel" exited with code 1

なので、ベースラインとなる画像をアップデートして、比較基準を生成してあげます。

bun lost-pixel:update

すると、次のように baseline-images にベースライン画像が格納されていることが確認できます。 ベースライン画像が生成される ベースライン画像が生成される

しかし、baseline-images と current-images の比較がベースライン画像のアップデートよりも先に行われている可能性があるからか、またもや例外がスローされます。

Screenshots done!
Creating shots took 13.659 seconds
🔍 Checking differences
Comparing 8 screenshots using 'pixelmatch' as compare engine
[1/8] Comparing 'home' (home)
[1/8] Baseline image missing. Will be treated as addition. (home)
[2/8] Comparing 'about' (about)
[2/8] Baseline image missing. Will be treated as addition. (about)
...
Comparison done!
Removing 0 files from ./tests/vrt/baseline-images
👋 Exiting process with 0 found differences & 8 baselines to update
Sending anonymized telemetry data.
error: script "lost-pixel:update" exited with code 1

2. もう一度差分確認をする

とはいえ、base-images の更新はできているようなので、もう一度差分の確認をします。

bun lost-pixel

すると、今回は例外はスローされずにプロセスが完了しました🎉

Screenshots done!
Creating shots took 12.356 seconds
🔍 Checking differences
Comparing 8 screenshots using 'pixelmatch' as compare engine
[1/8] Comparing 'home' (home)
[2/8] Comparing 'about' (about)
[3/8] Comparing 'dev' (dev)
...
[1/8] No difference found. (home)
[2/8] No difference found. (about)
[3/8] No difference found. (dev)
...
Comparison done!
  Checking differences took 0.014 seconds
  Lost Pixel run took 12.371 seconds
Sending anonymized telemetry data.

この流れを CI 上でもおこない、PR マージの際に自動チェックしていきましょう🏌🏻‍♀️

運用の準備

3. Workflowを作成する

Github Actions を用いて CI として動かすための Workflow ファイルを作成していきます。

運用シナリオで述べた次の流れを実現するために、次からの項目でvis-reg-test.ymlupdate-lostpixel.ymlを作成します。

  • featureブランチfeat/lost-pixelのPRチェック時にGithub ActionsでLost Pixel OSSモードを用いたVRTを行う
  • 差分が検出された場合は/update-vrtとPRにコメントを入れることで、ベースライン画像の更新PRlost-pixel-update/> [base-pr-name]を元ブランチfeat/lost-pixelから新たに作成する
  • lost-pixel-update/[base-pr-name]のベースライン画像の差分をImage Diffを用いて確認・レビューし、マージする
  • 元ブランチfeat/lost-pixel上でVRTが再度走り、チェックがpassする

(P.S. この CI workflow を作成するのに多くの時間を消費しました。私は CI にとても弱いです。(CI スパスパ作れる人々かっこいい。。。))

3.1 差分確認のためのWorkflow(vis-reg-test.yml)

vis-reg-test.ymlの役割はlost-pixel/lost-pixelというアクションを利用した単純な差分チェックです。

ワークフローの各ステップの内容は# 🟢...で示しています。

name: Visual Regression Test

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
    steps: # 🟢依存関係のインストールとキャッシュ
      - name: Checkout Commit
        uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - name: Setup Bun
        uses: oven-sh/setup-bun@v1
        with:
          bun-version: latest

      - name: Install dependencies
        run: bun install

      - name: Cache bun dependencies
        uses: actions/cache@v3
        id: bun-cache
        with:
          path: node_modules
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
          restore-keys: ${{ runner.os }}-bun
  lost-pixel:
    needs: build
    runs-on: ubuntu-latest
    strategy:
        matrix: 
          config:
            # 🟢 将来的にVRTしたいマイクロサービスが増えたらここに追加する
            - {
                package: "apps/blog.sakupi01.com",
                name: "Lost Pixel for blog page",
                command: "bun run start",
              }
    
    # 🟢stepが利用するデフォルトのディレクトリを上記${{ matrix.config.package }}の値にする
    defaults: 
        run:
            working-directory: ${{ matrix.config.package }}
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
    steps:
      - name: Checkout Commit
        uses: actions/checkout@v4
        with:
          fetch-depth: 2
      
      # 🟢.envを作成し、ビルド時に必要な環境変数を登録する
      - name: Create .env file 
        run: |
            touch .env
            echo "ZENN_URL=${{ secrets.ZENN_URL }}" >> .env
            echo "ZENN_BASE_URL=${{ secrets.ZENN_BASE_URL }}" >> .env

      - name: Setup Bun
        uses: oven-sh/setup-bun@v1
        with:
          bun-version: latest

      - name: Restore bun dependencies
        id: bun-cache
        uses: actions/cache@v3
        with:
          path: node_modules
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
          restore-keys: ${{ runner.os }}-bun
      
      - name: Build UI
        working-directory: ./packages/ui
        run: bun run build 

      # 🟢 ブログアプリのビルド
      - name: Build App
        run: cd ../../ && bun run build 

      # 🟢 サーバの立ち上げ。コマンドは${{ matrix.config.command }}の値を利用
      - name: Start App
        run: cd . && ${{ matrix.config.command }} &
       
       # 🟢 Lost Pixelを用いたVRT
      - name: Lost Pixel
        id: lostpixel
        uses: lost-pixel/[email protected]
        env:
            LOST_PIXEL_DISABLE_TELEMETRY: 1
            LOST_PIXEL_CONFIG_DIR: ${{ matrix.config.package }}
3.2 baseline更新のためのWorkflow(update-vrt.yml)
# 🟢 https://zenn.dev/aiji42/articles/6656072a954a9b を大変参考にさせていただきました!🙇🏻
name: Visual Regression Testing Update By PR Comment

on:
  issue_comment:
    types: [created, edited]

jobs:
  lost-pixel:
  # 🟢 PRに`/update-vrt`という文字列から始まるコメントがcreate OR editされたらジョブがスタートする
    if: contains(github.event.comment.html_url, '/pull/') && startsWith(github.event.comment.body, '/update-vrt')
    name: 📸 Lost Pixel Baseline Update By PR Comment
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
    
    # 🟢 GIthub ActionsにPRの作成のための権限を付与
    permissions:
      contents: write
      pull-requests: write
    
    # 🟢 モノリポの各サービスで運用するための設定
    strategy:
      matrix:
        config: 
          - { package: "apps/blog.sakupi01.com", name: "Lost Pixel for blog page", command: "bun run start" }
    
    # 🟢 stepが利用するデフォルトのディレクトリを上記${{ matrix.config.package }}の値にする
    defaults:
      run:
        working-directory: ${{ matrix.config.package }}

    steps:
      # 🟢 stepが利用するデフォルトのディレクトリを上記${{ matrix.config.package }}の値にする
      - name: Get branch name
        uses: xt0rted/pull-request-comment-branch@v2
        id: comment-branch
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}

      # 🟢 指定のブランチをチェックアウトする
      - name: Checkout Commit
        uses: actions/checkout@v3
        with:
          ref: ${{ steps.comment-branch.outputs.head_ref }}
          fetch-depth: 0

      # 🟢 .envファイルを作成し、ビルド時に必要な環境変数を設定する        
      - name: Create .env file
        run: |
              touch .env
              echo "ZENN_URL=${{ secrets.ZENN_URL }}" >> .env
              echo "ZENN_BASE_URL=${{ secrets.ZENN_BASE_URL }}" >> .env

      # 🟢 Gitの設定を行う
      - name: Setup Git User
        run: |
          git config --global user.name 'GitHub Actions'
          git config --global user.email '[email protected]'

      - name: Setup Bun
        uses: oven-sh/setup-bun@v1
        with:
          bun-version: latest

      # 🟢 依存関係をインストールする
      - name: Install dependencies
        run: cd ../../ && bun install

      - name: Build UI
        working-directory: ./packages/ui
        run: bun run build

      # 🟢 ブログアプリケーションをビルドする
      - name: Build App
        run: cd ../../ && bun run build

      # 🟢 アプリサーバを起動する
      - name: Start App
        run: cd . && ${{ matrix.config.command }} &

      # 🟢 Lost Pixelを使用してベースラインを更新する
      # 🟢 (point)比較はベースライン更新前に行われるため、必ず例外がスローされる仕組みになっている
      - name: Lost Pixel Update
        id: lostpixel
        uses: lost-pixel/[email protected]
        env:
          LOST_PIXEL_MODE: update
          LOST_PIXEL_DISABLE_TELEMETRY: 1
          LOST_PIXEL_CONFIG_DIR: ${{ matrix.config.package }}

      # ☑️ Lost Pixel Updateで例外スローの場合
      # 🟢 git add するファイルに対して権限を与える
      - name: Give Permission to untracked files
        if: ${{ failure() && steps.lostpixel.conclusion == 'failure' }}
        run: sudo chown -R $USER:$USER .
      
      # 🟢 既存のLost Pixel PRがあるかどうかを確認する
      - name: Check existing Lost Pixel PR
        id: check-pr
        run: |
          existing_pr=$(gh pr list --base ${{ steps.comment-branch.outputs.head_ref }} --label update-lost-pixel --json number,head.ref --jq '.[] | select(.head.ref | startswith("lost-pixel-update/")) | .number')
          echo "::set-output name=pr_number::$existing_pr"

      # ☑️ Lost Pixel Updateで例外スローの場合
      # 🟢 新しいブランチ(lost-pixel-update/[`/update-vrt`がコメントされたブランチ名])を作成する
      - name: Create new branch
        if: ${{ failure() && steps.lostpixel.conclusion == 'failure' && steps.check-pr.outputs.pr_number == '' }}
        run: |
          git checkout -b lost-pixel-update/${{ steps.comment-branch.outputs.head_ref }}

      # ☑️ Lost Pixel Updateで例外スローの場合
      # 🟢 既存のPRブランチをチェックアウトして新しいブランチ(lost-pixel-update/[`/update-vrt`がコメントされたブランチ名])にいく
      - name: Checkout existing PR branch
        if: ${{ failure() && steps.lostpixel.conclusion == 'failure' && steps.check-pr.outputs.pr_number != '' }}
        run: |
          git checkout lost-pixel-update/${{ steps.comment-branch.outputs.head_ref }}

      # ☑️ Lost Pixel Updateで例外スローの場合
      # 新しいブランチ(lost-pixel-update/[`/update-vrt`がコメントされたブランチ名])に
      # 🟢 変更されたファイルを追加する
      - name: Add untracked files
        if: ${{ failure() && steps.lostpixel.conclusion == 'failure' }}
        run: |
          git add .

      # ☑️ Lost Pixel Updateで例外スローの場合
      # 新しいブランチ(lost-pixel-update/[`/update-vrt`がコメントされたブランチ名])に
      # 🟢 コミットを作成する
      - name: Create commit
        if: ${{ failure() && steps.lostpixel.conclusion == 'failure' }}
        run: |
          git commit -m "Update VRT baseline images" || echo "No changes to commit"

      # ☑️ Lost Pixel Updateで例外スローの場合
      # 新しいブランチ(lost-pixel-update/[`/update-vrt`がコメントされたブランチ名])に
      # 🟢 変更をプッシュする
      - name: Push changes
        if: ${{ failure() && steps.lostpixel.conclusion == 'failure' }}
        run: |
          git push origin HEAD:lost-pixel-update/${{ steps.comment-branch.outputs.head_ref }} --force

      # ☑️ Lost Pixel Updateで例外スローの場合
      # 新しいブランチ(lost-pixel-update/[`/update-vrt`がコメントされたブランチ名])の
      # 🟢 PRを`/update-vrt`がコメントされたブランチに対して作成する
      - name: Create Pull Request
        if: ${{ failure() && steps.lostpixel.conclusion == 'failure' && steps.check-pr.outputs.pr_number == '' }}
        run: |
          gh pr create --base ${{ steps.comment-branch.outputs.head_ref }} \
                       --head lost-pixel-update/${{ steps.comment-branch.outputs.head_ref }} \
                       --label update-lost-pixel \
                       --title 'Lost Pixel Update

4. update-vrt.ymlをmainブランチに取り込む

実際に feat/lost-pixel ブランチの PR を作成してvis-reg-test.ymlのワークフローを回す前に、運用の下準備としてupdate-vrt.ymlを main ブランチに取り込んでおきましょう。

feat/lost-pixel ブランチにそのままupdate-vrt.ymlをコミットしたい気持ちですが、このままでは PR に/update-vrtをコメントしてもワークフローはトリガーされません。

update-vrt.ymlのトリガーは「PR に/update-vrtをコメントする」ことで、pull request のコメントが作成、編集されたときにワークフローを実行するために、issue_commentを利用しています。(update-vrt.ymlの内容を参照)

ここで気をつけたいのが、トリガーするイベントがissue_commentのワークフローファイルがデフォルトブランチに存在しないと、PRにコメントしてもワークフローはトリガーされないということです。

ワークフローをトリガーするイベント - GitHub Docs
GitHub で特定のアクティビティが発生したときに、スケジュールした時刻に、または GitHub の外部でイベントが発生したときに、ワークフローが実行されるように構成できます。
ワークフローをトリガーするイベント - GitHub Docs favicon docs.github.com
ワークフローをトリガーするイベント - GitHub Docs

したがって、前もってupdate-vrt.ymlを main ブランチにコミットしておきます。

運用する

6. vis-reg-test.ymlを回す

feat/lost-pixelの PR を作成してvis-reg-test.ymlで定義されているワークフローを回します。

次の 6.**のステップで、ワークフローの実行結果によってとる行動を示します。

6.1 Failのとき

/update-vrtとPRにコメントします。すると、先ほど定義したupdate-vrt.ymlのワークフローがトリガーされます。

これにより、baseline の update をするためのブランチ lost-pixel-update/[base-pr-name]がコメントを入れた PR から生える形で作成されます。


次に、タイトルが「Lost Pixel Update -[base-branch-name]」PR も作成されます。

早速、作成された PR を見ていきましょう👀

PR の Changes 部分を確認することで、Github 上で視覚的に見た目の変化を捉えることができます。

👇微々たる変化。この場合、キャプチャタイミングや実行環境の差異が原因であると思われる。 許容できる見た目の変化 フォントによるズレ(環境の差異として今回は許容)

ステップ6.1.1へ

👇見た目の大きな変化。この場合、コードベースに何らかの問題があると思われる。 許容できない見た目の変化 zennの記事を表示する変更を加えた際、定義した環境変数が正しく読み込まれていなかったことによる不整合ステップ6.1.2へ


6.1.1 許容可能な見た目の変化のとき

PR の差分を確認した結果、許容可能な見た目の変化の時は、コメント元のブランチ(feat/lost-pixel)に 6.1 で生成された PR をマージしたいです。

そうすることで、コメント元のブランチの baseline 画像がアップデートされ、vis-reg-test.ymlのワークフローのテストを pass することができるはずです。

許容可能な見た目の変化だと確認できたら、6.1 の PR をマージしましょう!

すると、コメント元のブランチ(feat/lost-pixel)でもう一度vis-reg-test.ymlのワークフローが周り、今度は update された baseline との比較が行われるため、テストを pass することができます

ステップ6.2へ


6.1.2 許容不可能な見た目の変化のとき

コメント元のブランチ(feat/lost-pixel)に戻って、見た目を揃えるための修正コミットを加え、再度 push します。

ステップ6へ

6.2 Successのとき

おめでとうございます!これで見た目が確認された変更をマージできます💯


まとめ

Lost Pixel と Github Actions を使用して VRT を運用するプロセスを具体例ベースでまとめました。

日本語文献どころか、英語文献ですら具体的なユースケースが出てきにくい Lost Pixel の使用で詰まってしまうところも多かったので、備忘録としての役目も兼ねて書きました。

今回はページ単位でのキャプチャの差分比較でしたが、Storybook を用いたコンポーネント単位のユースケースも考えられ、コストフリーで VRT をする場合、Lost Pixel は有力な候補になりそうです。

CI の workflow 部分上手く書けている気がしないので、私の周りの CI つよつよエンジニアの方からのアドバイスお待ちしています笑📨