🧩

VRTツールとしてLost Pixelを使ってみたらいい感じだった

2023/12/31に公開
1

Lost Pixel

https://lost-pixel.com/

Lost PixelとはWeb UIのビジュアル回帰テスト(VRT)のためのツールであり、キャプチャの撮影と差分の検出を一つのプロセスで行うことができるオープンソースライブラリです。

Web UIのVRTというと、storycap + reg-suit や、Playwrightのスナップショット機能、または Chromatic のようなサービスを思い浮かべる方も多いと思いますが、まさにそれらの類似ツールにあたるものであると捉えていただいて差し支えありません。

本記事では具体的な導入手順などは省き、Lost Pixelの特徴や他の類似ツールとの比較、または筆者の用途とカスタマイズについて紹介します。
記事を読んで、実際に導入を検討される場合のインストール方法やセットアップに関しては、公式のドキュメントを参照してください。

https://docs.lost-pixel.com/user-docs/

また、Lost Pixelにはプラットフォームモードと、OSSモードがあります。
プラットフォームモードは Chromatic のようなサービスで、Lost Pixelが公式に提供しているパイプライン上でVRTの実行と結果の確認が可能です。
OSSモードはGithub Actionなど、自身でパイプラインを用意して実行するモードで、筆者はOSSモードで利用しているため、本記事での説明は基本的にOSSモードを前提としています。

Lost Pixelの特徴

設定ファイルとベースライン画像ディレクトリ

設定ファイルはlostpixel.config.ts(js)の名前でプロジェクトに配備します。
ここに、キャプチャの計画(ブラウザエンジン・URL・ViewPort・撮影前のアクションなど)や、画像比較の計画(しきい値やマスキングなど)を記述します。
TS/JSでの記述が可能であるため、これらの設定・計画を動的に生成できます。

lostpixel.config.tsの例

import { CustomProjectConfig } from 'lost-pixel';

export const config: CustomProjectConfig = {
  storybookShots: {
    storybookUrl: 'examples/storybook-build/storybook-static',
  },
  // OSS mode 
  generateOnly: true,
};

また、Lost PixelはVRTのための比較元の画像(ベースライン)をプロジェクト内に保持します。
たとえば、reg-suitはS3など外部ストレージを自身で選択可能ですが、Lost Pixelはプロジェクト内に持つため、Gitなどのバージョン管理の対象となります。
これに関しては好き嫌い分かれるところだと思います。筆者が参画しているコンポーネントライブラリを開発しているプロジェクトでは、ベースライン画像がバージョン管理の対象となることで、レビュー時に必ず目を通す対象にできるので、むしろ助かっています。

Playwrightベース

Lost Pixelのキャプチャ部分はPlaywrightベースで作られています。そのため chromiumwebkitfirefox の3つのブラウザエンジンでキャプチャが可能です。
Playwrightを基盤としているものの、その詳細は抽象化されており、ユーザーはPlaywrightに関する深い知識がなくても簡単に導入できます。

また、この記事を書いている2023/12/31時点では、実行時のブラウザエンジンは上記の3つのうち1しか選択できませんが、同時に複数エンジンでの実行をサポートするPRを送ったところ、無事マージされたので、そのうち一度の実行で複数ブラウザエンジンでVRTができるようになるはずです。

https://github.com/lost-pixel/lost-pixel/pull/337

撮影モード

Lost Pixelにはサポートしている撮影モードが複数あります。

  • Storybook
  • Ladle
  • Histoire
  • Page shots
  • Custom shots

Storybook、Ladle、Histoireを使う場合、ビルド後のディレクトリやサーバのURLを設定ファイルに記入し、Lost Pixelを実行するだけで、自動的にHTTPサーバが起動し、定義された各シナリオ(ストーリー)のキャプチャを取得してくれます。

Page shotsでは、設定ファイルに記載されたURLリストを使って、Storybookなどのカタログ以外のWebページのキャプチャを行うことができます。設定ファイルがTS/JSで記述されているため、URLリストを動的に生成することも可能です。
また、Custom shots機能を使用すると、CypressやPlaywrightで事前に撮影した画像をそのまま比較分析に利用できます。

個人的にLost Pixelの推しポイントとして、これらのモードの組み合わせが可能であるという点が挙げられます。
これに関しては後に具体的なコードを交えて解説します。

撮影前のアクション

前述の通り、Playwrightがベースになっているため、Playwrightのスクリプトを書いてキャプチャを行う前の処理を挟むことが可能です。

import { CustomProjectConfig } from 'lost-pixel';

export const config: CustomProjectConfig = {
  pageShots: {
    pages: customPages,
  },
  beforeScreenshot: async (page) => {
    // Playwrightの構文で撮影前の操作が記述可能
  },
};

前述のPage shotsと併用し、撮影のURLのクエリパラメータにclick=.targetscroll=Npxなどを付けてアクセスするようにし、beforeScreenshot側でそれに対応する操作を書いておけば、ページごとに処理を分けることも可能です。

細やかな設定値

しきい値

差分検出時に、Failedとしてマークするためのしきい値です。
reg-suitでは、全体で一つのしきい値を設定することになりますが、Lost Pixelは各ストーリーやURLごとにしきい値が設定可能なのが特徴です(全体で共通の値を設定することも可能)。

筆者のプロジェクトでは、基本的にはしきい値は0とし、一部ブラーやグラデーションがかかるためにブレてしまうストーリーのみ、しきい値を設けています。

Storybookを対象とする場合、こんな感じで、ストーリーの設定に直接設定値を記述できます。

// Sample.stories.ts
export const Sample: StoryObj = {
  parameters: {
    // グラデーション部分に若干の差分が生じるためthresholdで緩和
    lostpixel: {
      threshold: 0.0001,
    },
  },
  // 省略
}

ちなみに、thresholdは0未満の値は%(パーセント)して扱われ、0以上はpx(ピクセル)として換算されます。

マスキング

キャプチャの対象を実際のページのURLにすると、ランダムな要素(広告のクリエイティブなど)を含むページを対象にしなければらないケースもあるでしょう。
そういった場合には、キャプチャ時のマスキング設定で対象要素を黒塗りにすることで対処できます。こちらも、しきい値同様に各ストーリーやURLごとに個別の設定ができます。

https://docs.lost-pixel.com/user-docs/api-reference/mask

なぜLost Pixelを採用したのか、どんなルールにしているか

Lost Pixel導入以前は storycap + reg-suit の構成を採用していましたが、下記のいくつかの理由からLost Pixelに変更しました。

ワークフローとルール

reg-suitの場合、reg-keygen-git-hash-pluginを使用して、比較先のコミットを決定していました。
基本的にトピックブランチの生え元のコミットのスナップショットデータが比較対象となりますが、必ずしも常に比較したい対象がブランチの生え元であるとは限りません。
また、その生え元にキャプチャのデータがない場合などもあり、リポジトリ内にベースライン画像を含めてしまった方が結果的に運用がスムーズになったという点がLost Pixelに切り替えた理由の一つです。

そして、ベースライン画像がリポジトリに含まれるため、コミッターは差分が発生することを認識しているのであれば、ベースライン画像の更新もコミットに含めてPRを作成することになります。
レビュワーはその差分も含めてAccept/Rejectの判断をします。
もし期待していない差分が発生するのであれば(あるいは更新忘れの場合)CIがFailedになり、コミッターは原因を調査して、コードを修正すべきかベースライン画像を更新すべきかを判断して再コミットします。

reg-suitの場合、差分レポートの機能があり、それが非常に使いやすいのですが、想定されている差分なのか否かという判断は、PRのメッセージやSlack上でのやりとりなどに残すしかありません。
「これは想定している差分ですよ」とベースライン画像の更新をコミットに残し、逆に「差分を解消しなければPRをマージできない」といった強いルールをワークフローに組み込みやすいというのも、Lost Pixel(もとい、ベースライン画像をリポジトリにもつこと)のメリットだと思います。

撮影モードの組み合わせと撮影前のアクション

筆者がLost Pixelを導入したプロジェクトは、コンポーネントライブラリを作っているプロジェクトで、Storybookをコンポーネントのカタログやドキュメントとして整備しています。
VRTには、それらのコンポーネントに対して、ホバーしたときのスタイルの変化や、スクロール後のビジュアルなど、何らかのアクションを行った後のキャプチャが必要です。
そういったケースでは、ストーリーにplay関数を書いて対応するのが一般的ですが、VRTのためのストーリーをドキュメントとしてのStorybookに含めたくないという気持ちがありました。

storycapの場合、parametersにクリックやフォーカスなどの簡易的な操作ルールを記述できるのですが、スクロールやキーボード操作、あるいはそれらの組み合わせなどには対応していません。
かといって、そのためにPlaywrightを入れてStorybookとともにメンテナンスするのは、整備・運用コストが見合うイメージが湧きませんでした。

Lost Pixelは撮影モードの組み合わせが可能であり、撮影前のアクションが簡単に書けるので、そのニーズにマッチしました。

戦略としては、

  • 基本的には、Storybookモード(storybookShots)で撮影
  • 追加で操作後のキャプチャが必要なものはPage shotsモード(pageShots)と撮影前アクション(beforeScreenshot)を組み合わせて撮影
    • ストーリーのtagsに予め決めておいたルールで操作内容を記述
    • stories.jsonからtagsを抽出し追加撮影用のURLのリストを生成
      • クエリパラメータに、操作内容を含めてアクセスするようにしておく
    • beforeScreenshotでクエリパラメータをパースし事前操作を挟んでから撮影

といった感じです。
実際の設定はこんな感じ(重要なところのみ抜粋)

// Sample.stories.ts
export const SampleStory = {
  // ストーリーにtagsで追加撮影ルールを設定
  // この場合、アンカーにホバーしてから撮影
  tags: ['vrt-hover-a[data-testid=SampleAnchor]'],
}
// lostpixel.config.ts
import { CustomProjectConfig, PageScreenshotParameter } from 'lost-pixel'
import { launchStaticWebServer } from 'lost-pixel/dist/crawler/utils.js'

// 追加撮影のURLのリストをstories.jsonから作成
const customPages = await import('./storybook-static/stories.json').then((storybookData) =>
  Object.entries(storybookData.stories).flatMap<PageScreenshotParameter>(([id, data]) => {
    return data.tags.flatMap((tag) => {
      // data.tagsから vrt-xxxx を取り出し、ストーリーのURL+クエリパラメータに変換
      if (!tag.startsWith('vrt-')) return []
      // ...省略
      return {
        path: `/iframe.html?id=${id}&${actions.map((action) => action.join('=')).join('&')}`,
        name: [id, ...actionKeys].join('-'),
      }
    })
  }),
)

// Lost PixelのサーバモジュールでStorybook用のサーバを立ち上げ
const server = await launchStaticWebServer('storybook-static')

export const config: CustomProjectConfig = {
  // Storybookモードで全ストーリーを事前操作なしで撮影
  storybookShots: {
    storybookUrl: server.url,
  },
  // Page shotsモードで事前操作が必要なストーリーを追加で撮影
  pageShots: {
    pages: customPages,
    baseUrl: server.url,
  },
  // Page shotsモード用の事前操作
  beforeScreenshot: async (page) => {
    const url = new URL(page.url())

    // ?hover=<locator:string> で指定された要素にホバーする
    const hoverTarget = url.searchParams.get('hover')
    if (hoverTarget) await page.hover(hoverTarget)

    // 同様に、クリックやキーボード操作、スクロールなどのアクションを設定
  },
  generateOnly: true,
}

こうして、StorybookモードとPage shotsモードを組み合わせることで、ピュアなコンポーネントカタログのVRTと、カスタム操作を行ったVRTとを一度に実行できます。
また、「VRTのためのストーリーをドキュメントとしてのStorybookに含めたくない」というこだわりも維持することができます。

Lost Pixelのまだまだところ

ドキュメントが充実していない

非常に便利で機能が豊富ですが、公式のドキュメントでは全ての設定についての説明がカバーされているわけではありません。
少し凝ったことをしようとすると、ドキュメントでは情報が足りず、ソースコードを追うことになります。
幸いにも、ソースコード自体がそんなに複雑ではないので全体像の把握は比較的容易だと思います。
しかし、本格的に導入してゴリゴリにカスタマイズしたい場合には、ソースコードを読み込み、時にはLost Pixelのリポジトリにコントリビュートする気概が必要です。

差分のビジュアライザが不十分

reg-suitやPlaywrightでは、VRTの結果を表示するためのツールが充実していますが、Lost Pixelは実行に、AfterとDiffの画像が生成されるのみです(Beforeはベースライン画像)。


diffの例

正直これだけだと、どんな差分が発生しているのかよくわかりません。
ただ、実際にはPRのChangesにあるimage diffで差分を確認できるので、レビュワー観点ではそこまで大きな問題にはなりませんでした。

https://www.tumblr.com/radiumsoftware/4081173936/githubの画像diffが凄い

パイプライン用のプラグイン

Lost PixelはGithub Actionを提供しており、CIで差分をチェックするだけであればスムーズに導入できます。

https://docs.lost-pixel.com/user-docs/setup/integrating-with-github-actions

しかし、ベースラインをアップデートする場合には、少し工夫がいります。
公式のドキュメントには、on: workflow_dispatchで手動でアクションを実行してベースラインのアップデートをするためのPRを生成する方法が紹介されています。

https://docs.lost-pixel.com/user-docs/recipes/lost-pixel-oss/automatic-baseline-update-pr

正直この方法はあまり実用的ではないので、PR上で本文の冒頭に/update-vrtと書いたコメントを投稿すると、対象のブランチにベースラインのアップデートを自動コミットされるワークフローを組んでいます。

name: Visual Regression Testing Update By PR Comment

on:
  issue_comment:
    types: [created, edited]

jobs:
  lost-pixel-update:
    # PR中のコメントに /update-vrt と入力するとアクションが発動する
    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
    permissions:
      contents: write
      pull-requests: read
    steps:
      - name: Get upstream branch
        id: upstreambranch
        run: |
          PR=$(curl -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" ${{ github.event.issue.pull_request.url }})
          echo "branchname=$(echo $PR | jq -r '.head.ref')" >> $GITHUB_OUTPUT
      # 省略(チェックアウトしたりStorybookをビルドしたりする)     
      - name: Lost Pixel
        id: lostpixel
        uses: lost-pixel/lost-pixel@v3.8.2
        env:
          LOST_PIXEL_MODE: update
          LOST_PIXEL_DISABLE_TELEMETRY: 1
      - name: Commit and Push
        uses: stefanzweifel/git-auto-commit-action@v5
        if: ${{ failure() && steps.lostpixel.conclusion == 'failure' }}
        with:
          commit_message: update lost-pixel baselines

まとめ

Lost Pixelは、VRTを行うためのツールで、キャプチャの撮影と差分の検出を一つのプロセスで行うことができるオープンソースライブラリです。
下記のような特徴を持っています。

  • Playwrightをベースにしており、異なるブラウザエンジンでのキャプチャが可能
  • 設定はTS/JSファイルに記述可能で動的な設定を生成可能
  • プロジェクト内にベースライン画像を保持するため、Git等でのバージョン管理を伴う
  • Storybookなどの複数の撮影モードをサポートし、撮影前のカスタムアクションを設定することが可能
  • 差分の検出精度を調整するためのしきい値をストーリー毎に設定できたり、必要に応じて特定の要素をマスキングする機能も備えている

筆者はLost Pixelを採用することで、撮影モードの組み合わせや撮影前のカスタムアクションを利用し、より精度の高いビジュアルテストを実現しています。
しかし、ドキュメントがまだ十分でなかったり、差分のビジュアライザが不十分だったりといった課題もあります。
CIに組み込むためのGitHub Actionの提供や、ベースライン更新プロセスの工夫など、運用上のヒントも提供しています。

Lost Pixelは、ユーザが独自にカスタマイズしやすく、多様なビジュアルテストのニーズに対応可能なツールとして、特に複雑なUIや頻繁に更新されるプロジェクトに適しています。
その柔軟性と拡張性は、高度なビジュアルテストを行う開発チームにとって大きなメリットとなります。


本記事を読んで少しでも興味を持っていただけた方は、ぜひ公式ドキュメントを読んで試しに使用してみてください。

https://docs.lost-pixel.com/user-docs/

GitHubで編集を提案

Discussion

tommy34tommy34

reg-suitだったらreg-keygen-git-hash-pluginでなくてreg-simple-keygen-pluginを使えば任意の比較元、比較先のコミット選択できませんか?