Visual Regression Testing で予期せぬレイアウト崩れを検知する

6 min read読了の目安(約6000字

Atomic Designなどに倣ってコンポーネントを作成している場合、
あるOrganisms(Atoms, Moleculesでも可)のコンポーネントの構造やスタイルを修正した際、参照しているTemplatesのレイアウトも変化します。

修正したコンポーネントが複数のTemplatesから参照されている場合、
「あるTemplatesのレイアウトの変更は想定したものだったが、それ以外のTemplatesのレイアウトが変わることを予期していなかった」 ということも考えられるでしょう。

このような場合、レビューの段階でも意図していない箇所の変更に気づきにくいため、QAで発見されるということになることがあります。(最悪リリース後に発見される👀)

そこで、Visual Regression Testing による予期していないレイアウト変更を検知する仕組みについて説明していきます。後半では、reg-suitを用いたテストの導入をしていきます。

Visual Regression Testing とは

そもそもの Visual Regression Testing(以降、VRTと記述する)はどのようなテストかについてですが、ブラウザにレンダリングした際のスクリーンショットをピクセル単位で差分を検出するテストです。

正解画像(Expected Image)と実際の画像(Actual Image)を比較し、
差分が閾値以下ならSuccess、しきい値を超えたらFailedになります。


Expected Image と Actual Image(reg-suitからの引用)

VRTでは差分をどれだけ許容するかの閾値が重要になります。
適切な値は各々によるかもしれませんが、僕は以下の記事を参考にしました。

https://tech.recruit-mp.co.jp/front-end/visual-regression-testing/#h-2

差分閾値は 0.1% (core.thresholdRate = 0.001)

私たちのチームでは 0.1%、つまり 32x32 のメッシュの 1 箇所以上で差分があれば検出するようにしています。カタログの規模的に false positive を目視でチェックしきれるので、なるべく false negative を出さないであろう値にしています。今後の運用で見直すかもしれません。

VRTの仕組みを作る

ここからはStorybookのVRTの仕組みづくりについて説明していきます(Storybookのセットアップは省いてます)
この記事では Cypress, reg-suit を用いました。

  • Storybook: コンポーネントカタログを作成する
  • Cypress: Headlessブラウザを起動してStorybookのスナップショットを取得する
  • reg-suit: キャプチャした画像と正解画像の比較、GithubやSlackへの通知、GCSとの連携

Storybookのスナップショット取得にCypressを使用しましたが、
reg-viz が提供している Storycap を用いても実現できます。Storycapは各Storiesをクロールしてくれるため、手軽にスナップショットを取得できるそうなので、試してみたいです👀

今回Cypressを採用したのは、

  • Storycapでは実現できない?ボタンのfocus時のキャプチャを取得したいなどの発展したことができるようにする
  • Storybookとスナップショット取得に関するコードを分離させる

Storycapの設定を*.stories.tsxに追加した際のコード

https://github.com/reg-viz/storycap#setup-your-storiesoptional

最終的にはこのような構成になりました。

Cypress

Headlessブラウザを起動してStorybookのスナップショットを取得するためのCypressのセットアップから説明します。

https://docs.cypress.io/guides/getting-started/installing-cypress
yarn add cypress --dev
yarn run cypress open

起動できること確認したら閉じて、cypress/integration/examplesディレクトリ以下は不要なので削除します。

次に、Storybookサーバが起動してからスナップショットを取得する必要があるので、start-server-and-testをインストールします。

https://github.com/bahmutov/start-server-and-test
yarn add -D start-server-and-test

スナップショット取得の処理を記述します。
CI上で動かすことを考えたときの適切なcy.wait(3000)の値を探る必要はありそうです。

cypress/integration/vrt/storybook.spec.ts
/// <reference types="cypress" />
context('Storybook', () => {
  it('logged in', () => {
    cy.visit('http://localhost:6006/?path=/story/example-page--logged-in');
    cy.wait(3000);
    cy.get('#storybook-preview-wrapper').screenshot('login');
  });
});

以下のコマンドをnpm-scriptsに記述した上で実行すると、
cypress/screenshotsディレクトリ以下にスナップショットが出力されます。

start-server-and-test 'yarn storybook' http-get://localhost:6006 'yarn cypress:run'

取得したスナップショットです。

focusを当てた状態のスナップショット取得

少し余談になりますが、StorycapではなくCypressを採用した理由として
特定の要素にfocusが当たった状態のスナップショットを取得すると前述しました。

Cypressのブログに方法は示されているので、focusを当てた状態のキャプチャを試してみました。

https://www.cypress.io/blog/2020/02/12/working-with-iframes-in-cypress/
cypress/integration/vrt/storybook.spec.ts
/// <reference types="cypress" />
const getIframeDocument = () => {
  return cy
  .get('iframe[data-is-storybook="true"]')
  .its('0.contentDocument').should('exist')
}

const getIframeBody = () => {
  return getIframeDocument()
  .its('body').should('not.be.undefined')
  .then(cy.wrap)
}

context('Storybook', () => {
  it('logged in', () => {
    cy.visit('http://localhost:6006/?path=/story/example-page--logged-in');
    cy.wait(3000);
    getIframeBody().find('#logout').focus();
    cy.get('#storybook-preview-wrapper').screenshot('login-focus');
  });
});

きちんとfocusが当たってることを確認できました。
(Cypressでhoverが上手くできなかったのが気になる🤔)

reg-suit

次に、キャプチャした画像と正解画像の比較、GithubやSlackへの通知、GCSとの連携などを行うreg-suitについて説明します。

npm i -g reg-suit
reg-suit init

最初はローカル環境でVRTを実行するために、plugins周りは省いてます。

regconfig.json
{
  "core": {
    "workingDir": ".reg",
    "actualDir": "cypress/screenshots",
    "thresholdRate": 0.001,
    "ximgdiff": {
      "invocationType": "client"
    }
  }
}
  1. .reg/expected ディレクトリに cypress/screenshots ディレクトリをコピーする
  2. コンポーネントに変更加える
  3. Cypressを実行する
  4. reg-suit run
  5. http-server .reg でローカルサーバ立ち上げる

pluginsの設定

再び reg-suit init を実行し、必要なpluginを追加します。
この記事では reg-notify-slack-plugin, reg-publish-gcs-plugin, reg-notify-github-plugin, reg-keygen-git-hash-plugin を追加しました。

reg-notify-github-plugincliendIdに関してはplugin追加時にGithub Appの追加と認証を要求されると思います。

regconfig.json
{
  "core": {},
  "plugins": {
    "reg-keygen-git-hash-plugin": {},
    "reg-notify-github-plugin": {
      "prComment": true,
      "prCommentBehavior": "default",
      "clientId": "need to setup"
    },
    "reg-notify-slack-plugin": {
      "webhookUrl": "need to setup"
    },
    "reg-publish-gcs-plugin": {
      "bucketName": "need to setup"
    }
  }
}

CIの設定に関してはreg-suitに注意点が示されているので参考になります。

https://github.com/reg-viz/reg-suit#run-with-ci-service

実際にコンポーネントに修正を加えた上でPRを作成してみましたが、
これでPRのコメントとしてレポートをしてくれるのでレビュワーにとって優しい状態を作れますね!

コンポーネントが増えるにつれて、Storybookの起動にかかる時間だけでなくCypressのスナップショット取得も時間がかかるのが懸念に感じてますが、
Atomic DesignにおけるTemplatesのみをビルド&キャプチャすることなども検討すると良さそうです。

参考記事