ENECHANGE Developer Blog

ENECHANGE開発者ブログ

スナップショットテストをやめて、ビジュアルリグレッションテストに移行した話

こんにちは、ENECHANGEの清水です。

普段の業務では主にフロントエンドの開発を担当していますが、最近はRailsでのバックエンドの開発にも参加しています。

今回は、スナップショットテストをビジュアルリグレッションテスト(以下 VRTと略)に移行した話について紹介したいと思います。

VRTへの移行の背景

私が携わっているプロダクトでは、予期せぬUIの変更を防止するため、JestとStorybookを用いてスナップショットテストを行っています。

スナップショットテストによって、予期せぬUIの変更は防止できているものの、プロダクトが成長していくに連れて、現行のテスト戦略の課題が徐々に明らかになってきました。

そのため、VRTへの移行を検討することになりました。

現行スナップショットテストの課題

まず現行のスナップショットテストの課題ですが、以下の4つがあります。

  1. 実装依存性
  2. 壊れやすさ
  3. テストの意図の不明瞭さ
  4. 大きな差分

実装依存性

スナップショットテストは、DOM構造cssのクラス名などの内部実装に強く依存しています。

そのため、ビジュアル要素としての見た目ではなくて、コードの構造に焦点が当たってしまいます。

その結果、実際のUIの成果物が具体的にどのようなものか把握しづらくなります。

例: タグを変更した場合

// 変更前のDOMツリー
<div class="container">
  <div class="header">タイトル</div>
  <div class="content">コンテンツ</div>
</div>

// 変更後のDOMツリー(<div> タグを <header> タグに変更):
<div class="container">
  <header class="header">タイトル</header>
  <div class="content">コンテンツ</div>
</div>

divheader タグへの単純な変更であっても、スナップショットテストは失敗します。ですが、目に見える成果物としては何の変化もありません。

壊れやすさ

実装依存性 でも触れたように、スナップショットテストはHTMLタグの変更やcssのクラス名の変更など、比較的小さなコードの変更であってもテストが失敗することが多いです。

そのため、小規模なリファクタリングでもスナップショットの更新が必要になり、開発の速度が遅くなるリスクがあります。

例: classを変更した場合

// 変更前
.btn-primary {
    color: blue
}
<button class="btn-primary">送信</button>

// class変更
.primary-button {
    color: blue
}
<button class="primary-button">送信</button>

目に見えるUIには影響がなかったとしても、classを変更しただけでスナップショットテストは失敗します。

テストの意図の不明瞭さ

スナップショットテストは単体テストやアサーションテストなどと比較すると、テストの意図が明確でないことが多いです。

また、HTML構造のみに依存しているため、実際のUIがどのように表示されるかを把握するのが難しいです。

そのため、最終的には実際にブラウザでUIの確認が必要になり、テストの効率が下がります。

また、開発者がテストの失敗原因を把握しないままでいると、次のような流れを引き起こしてしまう恐れがあります。

1. テストが予期せぬ理由で失敗する
2. 開発者が失敗の原因を理解せずにスナップショットを更新する
3. テストには成功するが、テスト本来の目的や信頼性が損なわれる

その結果、実際に予期せぬUIの変更があったとしても見逃すリスクを高めてしまう可能性があります。

大きな差分

スナップショットテストではDOMツリー全体を保存し比較するため、変更内容によっては大きな差分が発生してしまいます。

例: 共通のコンポーネントのタグを変更した場合

// SectionLayout.tsx

// 変更前のDOMツリー
<div>
    // テキスト
</div>

// 変更後のDOMツリー(<div> タグを <section> タグに変更):
<section>
    // テキスト
</section>

共通で使用されているコンポーネントのタグを変更した場合、そのコンポーネントを使用している全てのDOMノードがスナップショットの差分として生成されてしまいます。

ですが、このような大きな差分が発生しても、実際のUIに与える影響と直接的には比例しない場合が多いです。

逆にレビューが困難になったり、意図しない変更を見逃すリスクが高まります。

移行の目的

これらの課題に対処し、より効率的で信頼性の高いテスト戦略を確立するため、VRTへの移行を決めました。

VRTへ移行することで期待される改善点については以下になります。

テストの信頼性が高まる

見た目の変更に重点を置くことで、実装の細かな変更によってテストが誤って失敗するケースが減少するため、信頼性が向上する。

レビュープロセスの効率化

画像ベースで差分を検出するため、大量のコード差分に目を通す必要がなくなり、意図しない変更やバグを見逃すリスクが低減する。

開発プロセスの効率向上

  • テストの意図が明確になり、テストの実施とメンテナンスが容易になるため、全体的な開発プロセスが向上する
    • テスト実装の簡素化
      • Storybookと組み合わせることで、テストコードを書かなくても、Storyを追加するだけでテストができるようになる
    • エラー診断の高速化
      • コードではなくてビジュアル要素(画像)で比較できるので、エラーの原因を直感的に理解しやすくなり、診断と修正のプロセスが迅速化する

StorybookをベースとしたVRTのツール選定

私が携わっているプロダクトではStorybookを活用してUIコンポーネントを管理しています。

そのため、Storybookとの統合が容易であることが、テストツールを選定する上で重要な要素の1つです。

また、現行のスナップショットテストと同様にVRTをレビュープロセスに組み込みたいため、CI/CDプロセスとの統合の容易さについても重要視しています。

以下が検討したツールになります。

reg-suit + Storycap

reg-suit はCLIツールとして、画像の差分検出を担い、Storycap はStorybookの各ストーリーを自動でキャプチャし、画像として保存する役割を持ちます。

特徴

  • 画像の保存先は S3 や Google Cloud Storage などのストレージサービスを用意する必要がある
    • ストレージサービスとの連携を簡単にセットアップするためのプラグインも提供されているため、プラグイン(ストレージサービス)の選定が済めば導入のハードルはわりと低い
  • Github ActionsなどのCIと連携し、PR上で比較結果のサマリを出力してくれる

Chromatic

Chromatic は Storybook のホスティングや VRT をマネジドサービスとして提供してくれるSaaSです。

VRT としてできることは reg-suit + Storycap とほぼ同等です。

特徴

  • セットアップが簡単
    • Storybookを導入済みであれば、提供されているCLIを実行するだけで、キャプチャの取得からデプロイ、差分比較までやってくれる
  • 画像の保存先もChromatic側で用意してくれるため、ストレージサービスなどの検討をする必要がない
  • 1ヶ月あたり5000スナップショットまでは無料で利用できる

Chromaticについては、レビュープロセスには組み込んではいないものの、staging環境での検証用としてすでに導入しているので、移行コストは一番少ないです。

Playwright

Playwright は Microsoft が開発しているブラウザ自動操作ライブラリ。

他のツールと比べてテストコードを書く手間はありますが、

test("example test", async ({ page }) => {
  await page.goto("https://playwright.dev");
  await expect(page).toHaveScreenshot();
});

のようにページにアクセスして、toHaveScreenshot のような便利なメソッドを実行するだけで 、簡単に画面のスナップショットを画像として出力できます。

特徴

  • 導入のしやすさ
    • 画面をキャプチャするためのメソッドが提供されているので導入が楽
  • Storybookに依存しない
    • Storybookに依存しないので、仮にStorybookを使わなくなったとしてもテストができる
  • 画像の管理
    • 画像はGitで直接管理できるためストレージサービスを用意する必要がない

ツール選定結果

3つのツールを比較し、以下の観点からPlaywrightを採用することにしました。

  • 導入のしやすさ
    • ストレージサービスの設定なしに画像の管理ができる
  • コスト面
    • VRTをレビュープロセスに組み込むことを前提としているため、コストを気にせず気軽に使えることを重視している(PlaywrightでもCI実行のコストは発生する)

reg-suit + Storycapも検討しましたが、プラグインの選定や設定ファイルを書いたり、S3 のバケットなどを準備したりする必要があり、Playwright と比較すると準備量が多いため今回は外しました。

今後運用していく中で画像の管理周りで課題が見つかった場合には、また改めて検討し直したいと考えています。

VRTの設計

VRTの設計は以下になります。

  1. 開発者が新たにプルリクエストを作成
  2. プルリクエスト作成をトリガーに、GitHub Actionsが起動
  3. PlaywrightのDockerコンテナ内でStorybookの各Storyのスクリーンショットを撮影
  4. 撮影したスクリーンショットと、Git管理下にあるスクリーンショットを比較し、差分の有無を確認
  5. テストの結果
    1. 差分がなければテストに成功して終了
    2. 差分がある場合
      1. テストに失敗する
      2. テスト結果がArtifactsに出力される
      3. 開発者が差分を確認する
      4. 手元の環境でスナップショットを更新してpushし直す

vrtの全体フロー

実装の解説

ここからは具体的な実装について解説していきます。

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

  • @playwright/test
    • Playwrightのテストランナー
      • Playwrightを使用してブラウザ操作を実行し、VRTを行う核となるツールです
  • http-server
    • 静的ファイルサーバーとして機能し、ビルド済みのStorybookをローカルネットワーク上で簡単にホスティングできるようになります
  • start-server-and-test
    • 「HTTPサーバーを起動し、サーバーが応答可能になるまで待ってからテストを実行し、テスト後にサーバーを終了する」といった一連の操作を自動化してくれます
yarn add -D @playwright/test http-server start-server-and-test

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

1. ストーリーデータの読み込み

ビルド済みのStorybookからstories.jsonを読み込むことで、全てのStoryのメタデータにアクセスできます。

const storybookDir = resolve(__dirname, '../out/storybook');

const data = JSON.parse(
  readFileSync(resolve(storybookDir, 'stories.json')).toString(),
);

2. テストケースの定義

stories.jsonから取得したStoryをフィルタリングし、新しいブラウザページを開いて、指定されたURLへのアクセスとスクリーンショットの撮影を行います。

test.describe
  .parallel('visual regression testing', () => {
    const stories = data.stories as Story;
    const filteredStories = Object.values(filterStories(stories));
    for (const story of filteredStories) {
      test(`snapshot test ${story.title}: ${story.name}`, async ({
        browser,
      }) => {
        const page = await browser.newPage();
        await runSnapshotTest(page, story);
        await page.close();
      });
    }
  });

3. テストの実行

ストーリーごとにブラウザページを開き、ストーリーのIDを使用してそのURLにアクセスしています。

ページが完全に読み込まれてからスクリーンショットを撮影するため、ページにアクセスする際にwaitUntil: 'networkidle'として、ネットワークのアクティビティが完全に落ち着くまで待機しています。

それでも、ごく稀にテスト結果が安定しないことがあったため、waitForTimeoutを使って、ほんの気持ち程度ですが待機するようにしています。

/**
 * ストーリーのスナップショットを撮影する
 */
async function runSnapshotTest(page: Page, story: IndexEntry) {
  // ネットワークのアクティビティが落ち着くまで待機する
  await page.goto(`http://localhost:8080/iframe.html?id=${story.id}`, {
    waitUntil: 'networkidle',
  });

  await page.waitForTimeout(60)

  await expect(page).toHaveScreenshot(`${story.id}.png`, {
    // ぺージ全体をキャプチャ
    fullPage: true,
    // デバイススケールやアニメーションを無効にする
    scale: 'device',
    animations: 'disabled',
    // 画像間の差分を許容する度合いを数値で指定する。以下の場合は差分が10%未満は許容される
    threshold: 0.1,
  });
}

4. スキップしたいStoryがある場合

特定のストーリーをテストから除外したい場合は、skipVrtStories.jsonファイルにそのストーリーのIDを記述します。

例えば、ローディングのようなアニメーションが含まれているStoryを追加します。

{
  "skipStories": ["components-spinner--default"]
}

また、アニメーションが含まれているようなStoryに加えて、ドキュメンテーションのStoryもVRTでは不要なので除外します。

/**
 * テスト対象となるstoryを絞り込む
 */
function filterStories(stories: Story) {
   // docs はストーリー名に --docs が含まれる
  const EXCLUDED_KEYS = ['--docs'];
  return Object.keys(stories).reduce<Story>((filteredStory, key) => {
    const isExcluded = EXCLUDED_KEYS.some((excludedKey) =>
      key.includes(excludedKey),
    );

    if (!isExcluded && !skipStories.includes(stories[key].id)) {
      filteredStory[key] = stories[key];
    }
    return filteredStory;
  }, {});
}

最終的なコードは以下になります。

// .storybook/snapshot.spec.ts
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { IndexEntry } from '@storybook/types';

import { type Page, expect, test } from '@playwright/test';
import { skipStories } from './skipVrtStories.json';

type Story = Record<string, IndexEntry>;

// Storybookのデータを読み込む
const storybookDir = resolve(__dirname, '../out/storybook');
const data = JSON.parse(
  readFileSync(resolve(storybookDir, 'stories.json')).toString(),
);

// スナップショットテストを実行する
test.describe
  .parallel('visual regression testing', () => {
    const stories = data.stories as Story;
    const filteredStories = Object.values(filterStories(stories));
    for (const story of filteredStories) {
      test(`snapshot test ${story.title}: ${story.name}`, async ({
        browser,
      }) => {
        const page = await browser.newPage();
        await runSnapshotTest(page, story);
        await page.close();
      });
    }
  });

/**
 * ストーリーのスナップショットを撮影する
 */
async function runSnapshotTest(page: Page, story: IndexEntry) {
  // ネットワークのアクティビティが落ち着くまで待機する
  await page.goto(`http://localhost:8080/iframe.html?id=${story.id}`, {
    waitUntil: 'networkidle',
  });
 
  await page.waitForTimeout(60)

  await expect(page).toHaveScreenshot(`${story.id}.png`, {
    fullPage: true,
    scale: 'device',
    animations: 'disabled',
    threshold: 0.1,
  });
}

/**
 * テスト対象となるstoryを絞り込む
 */
function filterStories(stories: Story) {
  const EXCLUDED_KEYS = ['--docs'];
  return Object.keys(stories).reduce<Story>((filteredStory, key) => {
    const isExcluded = EXCLUDED_KEYS.some((excludedKey) =>
      key.includes(excludedKey),
    );

    if (!isExcluded && !skipStories.includes(stories[key].id)) {
      filteredStory[key] = stories[key];
    }
    return filteredStory;
  }, {});
}

3. scriptを追加

1. Storybookのビルド

  • prefixにpreという名前が付けられているのは、npmのライフサイクルスクリプトの規則に従っています。これにより、後述するstorybook:previewを実行する前に自動的にこのコマンドが実行されます。
  • -o オプションで出力先のディレクトリを指定
  • --test フラグを付与することでビルドが2~4倍高速になる
    • docgen 分析やドキュメントのコンパイルなどのプロセスがスキップされる
    • Chromatic Visual Test Addon ではデフォルトの設定になっている
    "build-storybook": "storybook build -o out/storybook/",
    "prestorybook:preview": "storybook build -o out/storybook/ --test"

2. ビルド済みのStorybookをローカルサーバーでホスティングする

http-serverHTTPサーバーを立ち上げて、 out/storybookディレクトリを公開します。

"storybook:preview": "http-server out/storybook"

3. VRTの実行

start-server-and-test は指定したURLが応答を返すまで待機し、その後に指定したコマンドを実行するツールです。

まず、storybook:preview を実行してStorybook をホストし、その後./.storybook 内のテストファイルを対象にPlaywright でテストを実行しています。

初回実行時は比較する画像がないため、テストには失敗してしまいますが、テスト時に撮影されたスクリーンショットの画像が出力されています。

そのため、続けてテストを実行した場合はテストに成功することが確認できます。

"storybook:vrt": "start-server-and-test storybook:preview http://localhost:8080 'playwright test ./.storybook'",

4. スナップショットの更新

Playwright のテスト実行時に --update-snapshots オプションを付けることにより、スナップショットが更新されます。

テスト対象のコンポーネントのUIに変更を加えてから、スナップショットを更新すると、変更後の画像が出力されます。

"storybook:vrt:update": "start-server-and-test storybook:preview http://localhost:8080 'playwright test ./.storybook --update-snapshots'

最終的に追加したscriptは以下になります。

{
 "build-storybook": "storybook build -o out/storybook/",
    "prestorybook:preview": "yarn build-storybook --test",
    "storybook:preview": "http-server out/storybook",
    "storybook:vrt": "start-server-and-test storybook:preview http://localhost:8080 'playwright test ./.storybook'",
    "storybook:vrt:update": "start-server-and-test storybook:preview http://localhost:8080 'playwright test ./.storybook --update-snapshots'",
}

4. DockerでVRTを実行する

Dockerを使用する理由

  • Playwrightが実行環境に依存しており、スクリーンショットで撮影された画像ファイルにもdarwin.pnglinux.png といった実行環境が含まれる名称になっています。
    • 例:
      • macOSで実行すると、foo-darwin.png というファイルが生成される
      • GitHub Actionsで実行した場合はfoo-linux.png というファイルが生成される
      • ファイル名が異なるので、比較対象の画像が見つからずテストにも失敗する
  • 実行環境のフォントによって表示が異なる
    • GitHub Actionsでubuntuを選択した場合に、フォントがTofuになっており、文字化けのような現象が起きていました

これらの課題を解決するためにDocker環境でVRTを実行する必要があります。

実装

Playwrightの特定のバージョン(v1.42.0)を含むDockerイメージを指定しています。

また、focalUbuntu 20.04 LTS(Focal Fossa)をベースにしたイメージであることを示しています。

# Dockerfile
FROM mcr.microsoft.com/playwright:v1.42.0-focal

WORKDIR /app
# compose.yml
services:
  playwright:
    build:
      context: ./
    volumes:
      - ./:/app
      - front_node_modules:/app/node_modules
    ports:
      - '9323:9323'
      tty: true

# ホスト側のnode_modulesに影響を与えないように名前付きvolumeにして分離
volumes:
  front_node_modules:

さらに、コンテナの起動から停止、コンテナ内での操作をコマンド1つで実行できるようにMakefileを用意しています。

これによりmake vrtmake vrt-update でVRTの実行やスナップショットの更新が簡単に行えるようになっています。

# コンテナの起動から停止、コンテナ内での操作をコマンド1つで実行できるようにMakefileを用意
vrt:
        docker-compose build && \
        docker-compose up -d playwright && \
        docker-compose exec playwright sh -c "yarn install && yarn storybook:vrt" && \
        docker-compose down playwright

vrt-update:
        docker-compose build && \
        docker-compose up -d playwright && \
        docker-compose exec playwright sh -c "yarn install && yarn storybook:vrt:update" && \
        docker-compose down playwright

5. CIでVRTを実行する

プルリクエストの作成をトリガーにVRTが実行されます。

また、任意のタイミングでも実行できるように手動でも実行可能にしています。

もしテストに失敗した場合は、ArtifactsにPlaywrightのレポートが出力され、htmlファイルで画像の差分を確認できます。

name: visual regression test

on:
  pull_request:
  workflow_dispatch:

jobs:
  vrt:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.42.0-focal
      env:
        LANG: ja_JP.UTF-8
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ github.head_ref }}
          fetch-depth: 0
      - uses: ./.github/actions/setup-node
        with:
          node-version-file: '.node-version'
      - name: Run visual regression test
        run: yarn storybook:vrt
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 1

これで全ての実装が完了し、VRTをレビュープロセスの中に取り入れることができました。

導入後に感じたこと

導入後に感じたメリット・デメリットについては以下になります。

メリット

  • テストの信頼性の向上
    • 見た目の変更に重点を置いたことで、スナップショットと比較して、実装の細かな変更によってテストが誤って失敗するケースが減少し、テストの信頼性向上しました
  • レビュープロセスの効率化
    • 成果物(画像)ベースで差分を検出するため、大量のコード差分に目を通す必要がなくなり、意図しない変更やバグを見逃すリスクが低減しました
    • 開発者が大量のコード差分のレビューから解放され、レビューが比較的容易になったので、より開発に集中できるようになりました
  • 開発プロセスの効率向上
    • テスト実装の簡素化
      • テストコードを書かなくても、Storyを追加するだけでテストができるようになりました
    • エラー診断の高速化
      • 成果物(画像)で比較できるので、エラーの原因を直感的に理解しやすくなり、診断と修正のプロセスが迅速化しました
    • 修正やリファクタリングに対するハードルの低下
      • デグレーションの心配もなくなったので、修正やリファクタリングに関しても導入前より気楽に行えるようになりました

デメリット

  • スナップショットの管理
    • スナップショットをgit管理しているため、スナップショットが増えると、リポジトリのサイズが大きくなる可能性があります
      • プロダクトの規模やテスト対象の数によっては、ストレージサービスから画像を取ってくるような工夫や、別の方法でVRTを実行することの検討が必要になります
  • 実行時間の増加
    • テスト対象のStoryが増えるたびにテストの実行時間が増加します
      • 特にクロスブラウザ対応が必要な場合、各ブラウザごとにテストを実行する必要があり、その分テストの実行時間も増加します

今後の課題

VRTへの移行後に新しく見えてきた課題は以下になります。

  • Storybookの管理方法
    • 新たに作成したコンポーネントをStorybookに追加し忘れた場合、VRTの対象になりません
    • 新たにコンポーネントを追加する際は hygen を使用して追加するルールを設けて、コンポーネントの追加と同時にStorybookのファイルも追加されるようにしたい
  • 実行時間の増加
    • プロダクトの成長に伴い、テスト対象のStoryも増加していくため、並列化やテスト対象の絞り込みによって最適化を行いたい
  • VRTが不要な変更に対してもVRTが実行されてしまう
    • READMEのみに変更があった場合など、VRTの実行が必要ないケースでもテストが実行されてしまいます
    • 指定したディレクトリのコードに変更があった場合のみ、VRTを実行するなどを検討して、VRT実行のトリガーを限定したい
  • CI実行コストの増加
    • プルリクエスト作成後はpushされる度に、全体に対してVRTが実行されます
    • 差分に対してのみVRTを実行できないか検討する

感想

スナップショットテストをやめてVRTに移行したことにより、大量のコード差分のレビューから解放され、より開発に集中できるようになりました。 また、スナップショットのようにテストコードを書かなくても、Storyを追加するだけでVRTの対象となるのは嬉しいです。

さらに、レビュアー側としても画像ベースで差分が見れるので、レビューがしやすくなったと実感しています。 この結果、開発者体験も向上し、チーム全体の生産性が向上したと感じています。

一方で、Storybookの管理方法や実行時間増加への対処など新しく見えてきた課題もありました。 今後はこれらの課題に取り組むことで、VRTの効果を最大限に引き出し、よりスムーズな開発プロセスを実現していきたいと考えています。

もし同じ問題を抱えている方がいらっしゃいましたら、参考になれば幸いです。

  翻译: