😙

【Puppeteer × reg-cli】実行中のローカル環境でVRテストを行なう(前編: ブランチを跨いでスクリーンショットを作成まで

2024/12/02に公開

VRT

VRT とは Visual Regression Testing のことで、画像による回帰テスト、つまりビューの変更差分を画像として比較しテストするものです。

テストとして得られる価値としては、スタイルシートの変更等でビジュアル面での変更が大小関係無く検知できるということがあります。
font-size 1pxの変更や設定している background-colorカラーコードの変更まで テストの結果として差分が出力されるので、改修に伴う予期しない変更の発見に繋がります。

ローカル環境でのVRTテスト

WebページのVRTとしてはStorybook上でコンポーネント単位のテストを行なう事が事例として多いですが、今回実施したい対象ではStorybookが整備されておらず、実際に表示されているローカル環境のブラウザビューを対象にテストを行なう事にしました。

VRTテストを実施するにあたり、必要になるのがテストを実施するための対象と、差分を比較するためのツールが必要になります。主にこれらを利用しました。

利用したツール

reg-cli

差分を作成するためのコマンドラインツールです。テスト対象のスクリーンショット画像を渡す事で対象の比較を行なってくれます。テスト結果をHTMLファイルとして生成してくれたり、使用方法がシンプルでわかりやすかったので今回採用しました。
reg-cliを内包したツールとしてreg-suitsがあります。こちらはテスト結果を外部ストレージに保存してくれたり、PRにテスト結果として添付してくれたりとreg-cliの上位版みたいなイメージですが、今回それらの機能は使おうとしていないので利用していません。

Puppeteer

よくスクレイピング等で使われる,Webブラウザを自動で操縦するためのツールです。
VRTテストのためにスクリーンショットを作るために使いました。
ブラウザの操縦ツールとしては、PlaywrightがPupeeteerの後発としてありますが、今回Puppeteerを使う理由が簡単なページ遷移とブラウザ操作、スクリーンショットの作成なので 自分が扱い慣れていたという理由も手伝いPuppeteerを採用しました。
とはいえPlaywrightの方が単純にブラウザの操作という面だけ見ても機能は多いですし、今後Playwrightに切り替える事は考えています。

simple-git

変更前・変更後のローカルビューをのリポジトリの切り替えで行ないたく、利用しました。
利用内容等は後述しますが、結構運用に任せた危うい使い方はしているつもりなので、何か別の方法が無いか探しています。

実現したいシーン

PullRequestの作成時等、作業結果を確認(そして予期しない変更が入っていないかのチェックも含めて)自分の作業リポジトリとその親リポジトリで画面を比較して差分チェックを行ないたいと思っています。

テストの流れ

ここでは、作業が行なわれた(変更が存在する)ブランチを作業ブランチ、作業ブランチを作成した元であるブランチを親ブランチと呼称します。

そして、テスト対象には サンプルとして create-next-appで作成したNext製のボイラープレートページをlocalhost:3001番で立ち上げて用意しました。
今回はこのページを対象としてテストを行ないます。

この記事内ではローカルリポジトリ内でブランチを変更し、自動的にテスト対象であるページのスクリーンショットを作成するところまで解説し、その後については後日公開する記事内で書きます。


生成されたページをそのままnpm run devで立ちあげた状態(こちらの画像は自分でスクリーンショットをとったものになります)


workingというブランチ名で作業ブランチとして切り出し、軽微な変更を加えました。
変更箇所は2点あるのですが、どこが変更されているかわかりますか ?

この内容でVRTを実施してみます。

カレントブランチが作業ブランチの状態でローカルページのスクリーンショットを作成する

変更後のスクリーンショットを先に作成し、保存します
ローカルページの起動自体は行なわれている事を前提と今回はしておきます。今後、長期的に運用するのであったり 同テストを他メンバーにも利用して貰うタイミングでここは整備しておこうと考えています。

カレントブランチを親ブランチに変更し、ローカルページのスクリーンショットを作成する

変更前のスクリーンショットを作成します。
ブランチの切り替えを行なう事で自分の作業環境のカレントブランチも変更されてしまうので、もし作業途中のものがあると作業内容が戻ってしまったりしてしまいそうですが 今回はコミットされていない変更が無い状態で実行するようにするという運用でカバーします。

作成した作業前・作業後のスクリーンショットを元にVRTを実行する。

作成したスクリーンショットを使って差分を確認するためにVRTを行ないます。
テスト結果を確認するために、生成されたレポート結果のHTMLファイルをローカルでホストします

実装内容

NodeJS環境の構築やパッケージインストールの手順等は省き、実際に実行ファイルとして利用した内容を説明していきます。各パッケージの使い方やインストール方法についてはそれぞれのドキュメントを参照してください (基本的にnpm経由でインストールするくらいだったと思います)。

ファイル構造から、それぞれのスクリプトファイルの内容を記述します。

ファイル構造

├── index.js // HTMLレポートファイルをホストする
├── package-lock.json
├── package.json
├── public
│   ├── diff
│       └── ...(ここにテスト結果の差分が画像として入ってくる)
│   ├── report.html (レポートHTMLファイル)
│   └── screenshots
│       ├── develop
│           └── ...(ここに変更前のビューのスクリーンショットが入ってくる)
│       └── work
│           └── ...(ここに変更後のビューのスクリーンショットが入ってくる)
├── reg.json
└── src
    ├── ScreenshotAction (ここに各ページでのスクリーンショットを行なうスクリプトファイルを追加していきます)
    │   ├── ScreenshotTopPage.js (実際にスクリーンショットを行なうタスク内容を記述する)
    ├── index.js // VRTの実行ファイル
    └── utility (汎用ファイル群)
        └── ScreenshotTask.js (スクリーンショットタスクを汎用的に作成するための基底クラス)

スクリーンショットの基底クラスファイル


/**
 * ScreenshotTask
 * puppeteerを使用してスクリーンショットを撮るための基底クラス
 * ページオブジェクト、開発フラグ、ビューポート設定を受け取る
 */
class ScreenshotTask {
  /**
   * @param {import('puppeteer').Page} page - PuppeteerのPageオブジェクト
   * @param {boolean} isDevelop - 開発用ブランチのスクリーンショットを撮るかどうかを示すフラグ
   */
  constructor(page, isDevelop) {
    this.page = page
    this.isDevelop = isDevelop
  }
  
  async execute() {
    // ビューポートの設定
    // 各タスク固有の処理
    await this.takeScreenshot()
  }
  
    /**
     * スクリーンショットタスク
     * ビューポートの設定を行い、スクリーンショットを撮影します。
     * 基本的に、takeScreenshotメソッド内でのスクショはこれを使ってください。
     * サブクラスでのオーバーライドは行わない想定です。
     */
    screenshot = async (label) => {
      const pictureDir = this.isDevelop ? 'develop' : 'work'
        try {
          await this.page.screenshot({ path: `public/screenshots/${pictureDir}/${label}.png`, fullPage: true })
        } catch (err){
          console.error(err)
        }
    }

  /**
   * スクリーンショットを撮影するメソッド。
   * このメソッドはサブクラスで実装される必要があります。
   * @throws {Error} - サブクラスで実装されていない場合にエラーをスローします。
   */
  async takeScreenshot() {
    throw new Error('takeScreenshot method must be implemented')
  }
}

export default ScreenshotTask

後述するScreenshotTopPage.js等でスクリーンショットを作成するための基底クラスの記述になります。今回のテストでは、Webサービス内の各ページであったり各機能をそれぞれテストファイルとしてファイルに分けて作成しました。それぞれのファイルでスクレイピングに使っているPuppeteerのスクリーンショットであったり、ブラウザのロードを待つ処理を追加するのもナンセンスだと思い 基底クラスを作成しました。

スクリーンショットを行なう各テストファイル

import ScreenshotTask from '../utility/ScreenshotTask'

class ScreenshotTopPage extends ScreenshotTask {
  async takeScreenshot() {
    await this.page.goto('http://localhost:3000/', { waitUntil: 'networkidle0' }) // <5>
    await this.screenshot('トップページ')
  }
}

export default ScreenshotTopPage

ScreenshotTaskクラスを基底とし、作成したファイルです。
指定のURL(今回だったらhttp://localhost:3000/)に移動し、表示されたページのスクリーンショットを作成します。

await this.page.goto('http://localhost:3000/', { waitUntil: 'networkidle0' })

スクリーンショットを作成するためのページに移動します。

{ waitUntil: 'networkidle0' }の所で移動後のページ読み込みが完了するまで待機しています。

wait this.screenshot('トップページ')

スクリーンショットを作成しています。

VRT実行ファイル

import path from 'path'
import puppeteer from 'puppeteer'
import simpleGit from 'simple-git'
import ScreenshotTopPage from './ScreenshotAction/ScreenshotTopPage'

const main = async () => {
  try {
    const browser = await puppeteer.launch({
      headless: false,
      args: [`--no-sandbox`, `--headless`, `--disable-gpu`, `--disable-dev-shm-usage`],
      defaultViewport: { 
        width: 1920,
        height: 1080,
        deviceScaleFactor: 2 
      }
    }) // <1>
    const accountPath = path.resolve(__dirname, '~/sand-box')
    const git = simpleGit(accountPath)
    const accountWorkingBranch = (await git.branchLocal()).current
    try {
      const page = await browser.newPage() 
      const branches = [accountWorkingBranch, 'develop']
      for (const branchIndex in branches) {
        await page.setCacheEnabled(false)
        await page.reload({waitUntil: 'networkidle2'})
        const branchName = branches[branchIndex]
        await git.checkout(branchName)
        const currentBranch = (await git.branchLocal()).current

        /**
         * ここからスクショのタスク群を実行
         */

        await new ScreenshotTopPage(page, branchName === 'develop').execute()
        
        /**
         * ここまでスクショのタスク群を実行
         */
      }
    } finally {
      // 作業ブランチに戻る
      await git.checkout(accountWorkingBranch)
      await browser.close() // <8>
    }
  } catch (err) {
    console.error(err)
  }
}

main()
const browser = await puppeteer.launch({
      headless: false,
      args: [`--no-sandbox`, `--headless`, `--disable-gpu`, `--disable-dev-shm-usage`],
      defaultViewport: { 
        width: 1920,
        height: 1080,
        deviceScaleFactor: 2 
      }
    })

利用するPuppeteerを作っています。
defaultViewportを指定して作成するスクリーンショットのサイズがぶれないようにしています。

const accountPath = path.resolve(__dirname, '~/sand-box')
const git = simpleGit(accountPath)
const accountWorkingBranch = (await git.branchLocal()).current

accountPathにテスト対象となるページのディレクトリを指定します。これはこの後ブランチを切り替える時に使います。
accountWorkingBranchには作業ディレクトリの現在のブランチ名を保存しておきます。これは、この後、 親ブランチに移動した後、作業ブランチに戻ってくるために利用します。

const branches = [accountWorkingBranch, 'develop']

作業ブランチと親ブランチ(今回は親ブランチがdevelopでした)のセットの配列を作成します。
この後スクリーンショットを作成・保存をふたつのブランチで行なうわけですが、行なうブランチは違えど、やる事は同じなのでループで回したいと思い、こうしています。

await page.setCacheEnabled(false)

Puppeteerで操作されるブラウザは仮想ブラウザになるわけですが、初回表示時やブランチの切り替えや直前の操作や直前行なっていたテストでの操作がスクリーンショットの作成に影響を与えていると余計な差分が生じる原因になっています。例えば、作業ブランチで何かにログインしたりフォームに入力してからスクリーンショットを撮った後、作業ブランチに移動してログインしたり再度フォームに入力しようとすると、既にログイン済みであったり、さっき入力した内容が残っていたりしていて正常に動作しなくなるわけです。
なので、それを防ぐためにブラウザにキャッシュされる設定を切っておきます。

await git.checkout(branchName)

ブランチの切り替えをsimple-gitで行ないます。この時点でコミット状態、gitのテーブル状態では失敗してしまうのですが、そこはなんとか運用でカバーしています。将来的になんとかしたいです(そもそもローカルのgitステータスをここで操作するのが良くないとは思っています。)

await new ScreenshotTopPage(page, branchName === 'develop').execute()

スクリーンショットを作成したりページを移動するスクリプトを実行します。

実行

この状態で
rm -rf ./public/screenshots/develop/* ./public/screenshots/work/* public/diff/* && babel-node src/index.js
を実行する事で 現在保存されているスクリーンショット類を削除し、新しくスクリーンショットを作成するためにsrc/index.jsが実行されます。

puppeteerにより作成されたスクリーンショット



このタイミングでは、VRTが実施されていないため、まだ差分ファイルは作成されていません。

次回は、今回作成したスクリーンショットを元にVRTを実行してみます。

SKIYAKI Tech Blog

Discussion