見出し画像

PRベースのビジュアルリグレッションテストをStorybookとPlaywrightで実装する noteUIDev#3

前回はPlaywrightでスクリーンショットを撮ってアーカイブする話をしました。

今回はPlaywrightのスクショ撮影を、Storybookに登録したStoryにおこなって、ビジュアルリグレッションテスト(VRT)をPull Request(PR)に対して実行して、コメントとして変更を通知する話です。

はじめに

新しい機能の追加や既存のコードのリファクタリングなど、開発の過程でUIに予期しない副作用が発生することがあります。VRTはそういった副作用のうち見た目上の変化を確認でき、自動化すると有用なブロッカーになります。

noteでは一部のプロジェクトにChromaticを導入してVRTを実施しています。Chromaticのインターフェースはわかりやすく多機能ですが、よりライトな形(コスト・運用面)で実装したいと思いました。そこで、StorybookとPlaywrightを使ってPR上で実施し、変更があればコメントとして投稿するようにしました。

(Chromaticは最高*)

システムの設計

具体的な処理のフローは、以下の通りです。

  1. 開発者が新たにPRを作成

  2. PR作成をトリガーに、GitHub Actions(CI)が起動

  3. CI内で、Playwrightを使ってStorybookの各Storyのスクリーンショットを撮影

  4. 撮影したスクリーンショットと、以前に撮影したスクリーンショットを比較し、差分があるかを確認 

  5. 差分がある場合

    1. その情報をPRのコメントとして投稿

    2. 新しい画像をコミット

実装

具体的なコードで説明します。前提としてStorybookでコンポーネントを管理していて.storiesファイルがあること、その開発環境があることとします。

1. 必要なパッケージのインストール

まず、必要なパッケージをインストールします。

pnpm add -D @playwright/test http-server start-server-and-test

合わせてpackage.jsonにコマンドも書いておきます。

{
  "scripts": {
    "vrt": "start-server-and-test preview-storybook http://localhost:8080 'playwright test ./.storybook --update-snapshots'",
    "preview-storybook": "http-server storybook-static",
    "prepreview-storybook": "storybook build"
  },
  "devDependencies": {
    "@playwright/test": "1.39.0",
    "http-server": "14.1.1",
    "start-server-and-test": "2.0.1",
  }
}

1-1 prepreview-storybook: "storybook build" 
Storybookのビルドを行います。 prepreview-storybook という名前が付けられているのは、npmのライフサイクルスクリプトの規則に従っています。これにより、preview-storybookを実行する前に自動的にこのコマンドが実行されます。

1-2 "preview-storybook": "http-server storybook-static"
ビルドしたStorybookをローカルサーバー上でホストします。 http-server はシンプルなHTTPサーバーを立ち上げるためのツールで、ここでは storybook-static ディレクトリを公開します。

1-3 "vrt": "start-server-and-test preview-storybook http://localhost:8080 'playwright test ./.storybook --update-snapshots'"
VRTを実行します。 start-server-and-testは指定したURLが応答を返すまで待ち、その後指定したコマンドを実行するツールです。ここでは、まずpreview-storybookコマンドを実行してStorybookをホストし、その後Playwrightのテストを実行します。テストでは、 ./.storybookディレクトリ内のテストファイルが対象となり、 --update-snapshotsオプションによりスナップショットが更新されます。

2. スクリーンショットの撮影

次に、Playwrightを使用して各Storyのスクリーンショットを撮影します。

const { expect, test, devices } = require('@playwright/test')
const { readFileSync } = require('node:fs')
const { resolve } = require('node:path')
const { skipStories } = require('./skipVrtStories.json')

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

const storybookDir = resolve(__dirname, '..', 'storybook-static')
const data = JSON.parse(readFileSync(resolve(storybookDir, 'stories.json')).toString())

test.describe.parallel('visual regression testing', () => {
  Object.values(filterOutDocsKey(data.stories)).forEach((story) => {
    if (skipStories.includes(story.id)) {
      console.log(`Skipping ${story.title}: ${story.name} as it's in the skip list.`)
      return
    }

    test(`snapshot test ${story.title}: ${story.name} (Desktop)`, async ({ browser }) => {
      const page = await browser.newPage()
      await runSnapshotTest(page, story, 'Desktop')
      await page.close()
    })
  })
})

async function runSnapshotTest(page, story, deviceType) {
  await page.goto(`http://localhost:8080/iframe.html?id=${story.id}`, {
    waitUntil: 'networkidle'
  })
  await sleep(60)
  expect(await page.screenshot({ fullPage: true })).toMatchSnapshot(
    [story.title, `${deviceType}`, `${story.id}.png`],
    { threshold: 0.1 }
  )
}

/**
 * ドキュメンテーションの story を除外する
 * docs はストーリー名に --docs が含まれる
 */
function filterOutDocsKey(obj) {
  let filteredObj = {}

  for (let key in obj) {
    if (!key.includes('--docs')) {
      filteredObj[key] = obj[key]
    }
  }

  return filteredObj
}

ステップに分けて説明します。

2-1 ストーリーデータの読み込み
まず、Storybookのストーリーデータを読み込みます。これは、Storybookが出力するstories.jsonファイルを読み込むことで行います。このファイルには、すべてのストーリーの情報がJSON形式で格納されています。

const storybookDir = resolve(__dirname, '..', 'storybook-static')
const data = JSON.parse(readFileSync(resolve(storybookDir, 'stories.json')).toString())

stories.jsonを出力するには、次のように.storybook/main.tsでオプションを有効にします。

import { StorybookConfig } from '@storybook/react-vite'

const config: StorybookConfig = {
  features: {
    buildStoriesJson: true
  }
}

2-2 テストケースの定義
次に、各ストーリーに対してテストケースを定義します。スキップリストに含まれているストーリーはスキップします。それ以外のストーリーに対しては、スナップショットテストを行います。

test.describe.parallel('visual regression testing', () => {
  Object.values(filterOutDocsKey(data.stories)).forEach((story) => {
    if (skipStories.includes(story.id)) {
      console.log(`Skipping ${story.title}: ${story.name} as it's in the skip list.`)
      return
    }

    test(`snapshot test ${story.title}: ${story.name} (Desktop)`, async ({ browser }) => {
      const page = await browser.newPage()
      await runSnapshotTest(page, story, 'Desktop')
      await page.close()
    })
  })
})

2-3 スナップショットテストの実行
最後に、スナップショットテストを行う関数を定義します。この関数では、指定されたストーリーのページに移動し、スクリーンショットを撮影します。撮影したスクリーンショットは、以前に撮影したスクリーンショットと比較され、差分がある場合は画像が上書きされます。

async function runSnapshotTest(page, story, deviceType) {
  await page.goto(`http://localhost:8080/iframe.html?id=${story.id}`, {
    waitUntil: 'networkidle'
  })
  await sleep(60)
  expect(await page.screenshot({ fullPage: true })).toMatchSnapshot(
    [story.title, `${deviceType}`, `${story.id}.png`],
    { threshold: 0.1 }
  )
}

waitUntil: 'networkidle' のオプションを指定することで、ネットワークのアクティビティが落ち着くまで待つようにしています。await sleep(60) はちょっとおまじないのような感じなのですが、レンダリングが完全に終わるのを待つために入れています。

3. スキップしたいidを指定

skipVrtStories.jsonというJSONファイルを用意して、アニメーションなどが含まれているようなStoryではVRTの対象から外すようにしておきます。

{
  "skipStories": [
    "components-like-like--is-animating"
  ]
}

4 . Github Actions で実行

このActionの流れは大まかに次のようになっています。

  1. まず、プルリクエストが作成されたとき、または手動でワークフローが実行されたときに開始

  2. 依存関係がインストールした後、Playwrightをインストール

  3. VRTが実行され、その結果をスナップショットとして保存

  4. スナップショットに変更があった場合、その変更がコミットされ、リポジトリにプッシュ

  5. 最後に、変更情報をプルリクエストのコメントとして投稿

name: visual regression test

on:
  pull_request:
    branches:
      - develop
  workflow_dispatch:

env:
  NODE_VERSION: 20.5.0
  UI_DIR: "./xxx"

jobs:
  vrt:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          ref: ${{ github.head_ref }}
          fetch-depth: 0

      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: Cache pnpm modules
        uses: actions/cache@v3
        with:
          path: ~/.pnpm-store
          key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-

      - name: Setup pnpm
        uses: pnpm/action-setup@v2

      - name: Install Dependencies
        run: pnpm i
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}

      - name: Install Playwright
        run: pnpm install playwright -w && pnpm exec playwright install --with-deps

      - name: Run Visual Regression Test
        id: tests
        working-directory: ${{ env.UI_DIR }}
        run: pnpm run storybook:VRT
        continue-on-error: true

      - name: Commit and Push If Changes Detected
        id: commit
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git add -A
          git reset package.json pnpm-lock.yaml
          git commit -m "Add updated visual regression diff images" && echo "commit_success=true" >> $GITHUB_ENV || echo "commit_success=false" >> $GITHUB_ENV
          git push

      - name: List changed snapshot files
        id: list-changed-snapshots
        run: |
          changed_files=$(git diff --name-only HEAD HEAD~1 | grep '.png' | tr '\n' ',')
          echo "Changed snapshot files: $changed_files"
          echo "::set-output name=changed_files::$changed_files"

      - name: Get the latest commit SHA
        id: get-sha
        run: echo "::set-output name=sha::$(git rev-parse HEAD)"

      - name: Comment PR with diff images and logs
        if: env.commit_success == 'true'
        uses: actions/github-script@v3
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const fs = require('fs');
            const path = require('path');
            const changedFiles = "${{ steps.list-changed-snapshots.outputs.changed_files }}".split(',');
            if (changedFiles[changedFiles.length - 1] === '') {
              changedFiles.pop();
            }
            
            let commentBody = "## 📸 Playwright Snapshot Update Log\n\n";
            commentBody += "スナップショットが更新されました。**意図した変更内容かどうか**確認しましょう。";
            commentBody += "<details><summary>Updated Snapshots</summary>\n\n";
            
            for (const file of changedFiles) {
              if (file) {
                const filePath = `${process.env.GITHUB_WORKSPACE}/${file}`;
                const fileName = path.basename(filePath);
                const fileUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${{ steps.get-sha.outputs.sha }}/${file}`;
                commentBody += `- [${fileName}](${fileUrl})\n ![Updated snapshot for '${fileName}'](${fileUrl}?raw=true)\n`;
              }
            }
            
            commentBody += "</details>\n";
            commentBody += `- Total updated files: ${changedFiles.length}\n`;
            
            await github.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: commentBody,
            });

storyは次のようにキャプチャーされコメントされます。キャプチャリストは嵩張ったときのために、<detail>で括っておくとよさそうでした。

PRへのコメントのようす

結果と考察

というわけで、このシステムを導入することで、開発者はPRを作成時に自動的にビジュアルリグレッションテスト(VRT)を実行し、その結果を確認できるようになりました。これにより、PRごとの見た目でわかる副作用を簡単に確認できます。

具体的には、VRTがあると、外部ライブラリのアップグレードや共通のCSSなど、影響範囲が広い施策を安心して実装できるのがとても嬉しいですね。noteではcommon-**といったような共通ライブラリをいくつも管理しているので、これがないと目視で判断することになり、なかなかつらい状態になってしまいます。

また、Playwrightの対象をstoryにしているので、play関数を活用していると、ユーザーのインタラクションの結果も保証できるという面もあります。ちょっとしたことですが、新しいコンポーネント作成時に、自動的にスクショをPRに貼れるというところも便利かなと思います。

現状でもやりたいことはできていますが、テストの効率化や結果の可視化、また他のテストの棲み分けについてなど、考えるべきことやカイゼンポイントを見つけつつ、このような取り組みで安心して開発できる環境を育てていきたいと考えています。

おわります。