upinetree's memo

Web系技術の話題や日常について。

BackstopJS を利用した Visual Regression Testing の感想と工夫ポイント

Visual Regression Testing とは

CSSやコードの変更によって、デザインに意図せぬ影響・リグレッションがないかを視覚的に確認するためのテスト。 変更前後のスクリーンショットを撮影して、差分を出力する方法が一般的。

似たテストに Snapshot Testing があるが、これはHTMLやASTを比較するテストであるため、CSSを含めたデザインの確認はできない。

やってみた感想

CSSリファクタリングでは、見た目が変わっていないことや、見た目の変更が意図通りであることを確認する工程が多くを占める。 変更が大量にある場合はすべてをチェックすることは現実的ではないため、ある程度サンプリングして確認することになる。従って確認漏れの可能性は残る。 さらに、目視での確認は不確実でもあるし、何度も行っていると疲れて精度が落ちてくる(あと、とにかくつらい… :sob: )。

テストがあると、これらの作業を効率化したうえで、安心してリファクタリングできるようになる。 具体的には、次のシチュエーションで効果を実感できた。

  • 全体のレイアウトに広く影響があるような修正を行うとき
    • たとえば、プロジェクト初期のCSSの負債を返すとか、以下のようなケースでは思いもよらぬ影響が起こりやすい
      • クラス名から推測できないような使われ方をしてるクラスの責務を適切に分離するとき
      • 無秩序にカスケードしていたクラスを整理するとき
      • 広くマッチするセレクタの範囲を狭めるとき
    • そんな時でもある程度安心して変更を加えられる
  • 新しく実装する機能のマークアップを行うとき
    • 最初は雑にマークアップして、ひとまず最短で体裁を整えることを目指す
    • その後、見た目を変えないよう設計をリファクタリング、というのがストレスなくできる
  • 副次的に、レビュー依頼など変更を説明するとき
    • レポート画面をスクショして提示するだけで、before/after+差分画像で変更を説明できて楽かつわかりやすい

プログラミングの自動テストと同じ感覚で、効率化と安心感をもたらしてくれた。

逆に微妙な点は、

  • 実行時間が長い
    • アプリケーションや環境にもよるが、100画面超えてくると10分超かかる
    • 並列実行数を増やすとテスト結果が不安定になるので高速化にも限界がある
    • 流して放置しつつ休憩 or 作業することができるので、目視時代よりは格段に楽
  • テスト結果が不安定
    • 今のところ開発環境で立ち上げたサーバでテストしているので、データの状態や実行時間などの影響が大きい
    • スクリーンショットを撮影するタイミングで結果が変わることがある
    • 上記は工夫次第で解決できそうだが、そのための労力が必要

BackstopJS

今回は、BackstopJSを使って実施した。 BackstopJSはスクリーンショット撮影、比較、比較結果のビジュアライズ、レポートUIといった、テストに必要な機能を十分満たしていて、かつメンテナンスも活発であることから採用した。 最近の開発シーンだとStorybookなどと組み合わせて行うのがモダンなのだろうけど、今回はそこまでJSを多用するプロジェクトではないのと、コンポーネントを組み合わせたときのレイアウトも確認したいため、どんな画面でもテストできることを重視した。

BackstopJSでは、例えばテストに失敗すると、以下のように差分が確認できる。

f:id:upinetree:20180513230107p:plain

before/after の目視での比較も可能。

f:id:upinetree:20180513230126g:plain

詳細はBackstopJSのREADMEを確認されたし。

利用にあたっては、標準の状態では使い勝手の良くない部分があったので色々工夫した。 BackstopJSはブラウザの操作に使うスクリプト./backstop_data 下に出力するので、そこをカスタマイズすることで大体解決できる。

そのカスタマイズ結果を他のプロジェクトでも使えるように汎用化して、雛形として公開してみた。

https://github.com/upinetree/backstop_boilerplate

大体コードを眺めればわかるのだが、せっかくなので以下では工夫したところやハマったところを解説してみる。

工夫ポイント

複数クリックイベント

オプション clickSelector はクリックするセレクタを1つしか指定できないが、あるセレクタをクリックして、次にこのセレクタをクリック、ということをしたい場面は結構ある。 そのため複数のセレクタを連続でクリックする clickSelectors を指定可能にした。

// backstop_data/engine_scripts/chromy/clickAndHoverHelper.js
const clickSelectors = (scenario.clickSelectors || [scenario.clickSelector]).filter(x => x);

clickSelectors.forEach((clickSelector) => {
  console.log(`INFO: click ${clickSelector}`);

  chromy
    .wait(clickSelector)
    .click(clickSelector);
});

(Chromyを利用しているが、現時点でBackstopJSはPuppeteerにも対応しているので、そっちでも利用できるようにしたい…)

UserAgent を設定する

viewport の設定による画面サイズごとのテストはサポートしているが、標準ではそこにUAを付けてくれない。 なので、 viewport の内容を見てUAを切り替える処理を自分で書き加える。

backstop.jsononBeforeScript に指定されているスクリプトで、以下を記述。

// backstop_data/engine_scripts/chromy/onBefore.js
if (vp.label === "phone") {
  chromy.userAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1");
} else {
  chromy.userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36");
}

Cookieの読み込み動作周り

初期状態では、Cookieは毎回 cookiePath オプションでテストシナリオごとに指定しなければいけないが、ログインが前提のアプリケーションでは使いづらい。 デフォルトのCookieを読み込むように変更した。

// backstop_data/engine_scripts/chromy/loadCookies.js
const defaultCookiePath = "backstop_data/engine_scripts/cookies.json";
const cookiePath =
  scenario.cookiePath === "" ?
    "" :
    scenario.cookiePath || defaultCookiePath;

また、BackstopJSが生成したクッキー読み込みスクリプト loadCookies.js ではテストシナリオごとにCookieの中身を出力するので、ログが流れてしまい追いづらい。 必要ないときは出力を抑制するようにした。

// backstop_data/engine_scripts/chromy/loadCookies.js
if (process.env.VERBOSE) {
  console.log("Cookie state restored with:", JSON.stringify(cookies, null, 2));
} else {
  console.log("Cookie state restored.");
}

テストシナリオの複数JSONへの分割機能

BackstopJSは標準ではテストシナリオを backstop.json に以下のように他の設定と同列に書く。

scenarios: [
  {
    "label": "xxx",
    "url": "http://localhost:3000/xxx",
    "selectors": [
      ".aListOfStuff li"
    ]
  }
]

これだと、テストシナリオが増えたときに管理しづらくなる。

そのため backstop_data/scenarios 内の複数のJSONファイルに分割して管理できるようにした。 実行時にはこれらのJSONをそれぞれ読み込み、以下のようにひとまとめにする( backstop_data/scenarios/index.json )。

const fs = require("fs");

const jsonFiles = fs.readdirSync(__dirname).filter(filename => filename.match(/\.json$/));
const scenarios = jsonFiles.reduce((acc, filename) => (
  acc.concat(require(`./${filename}`))
), []);

module.exports = scenarios;

こうしてひとまとめになったJSONをBackstopJSに渡す、という処理を backstop_data/runner.js に記述し、yarnスクリプトから呼び出すようにした。 (runner.js については後述)

backstop_data/runner.js

const backstop = require("backstopjs");
const config = require("../backstop.json");
const scenarios = require("./scenarios");

config.scenarios = scenarios;

backstop(command, {
  filter,
  config
});

package.json

{
  "scripts": {
    "backstop": "node ./backstop_data/runner.js"
  }
}

テストシナリオのJSONへの変数展開機能

例えば商品詳細画面のように、テスト対象のURLには動的なものもある。 そのようなURLを決め打ちでJSONに記述すると、以下のようにテスト環境が変化したときにURLが無効になってしまう。

  • URLのパラメータにしていたレコードの ID, UUID, 名前, etc が変わる
  • レコードが削除される

このような環境変化をある程度吸収するために、JSON読み込み時に変数展開を行えるようにした。 たとえば次のように、 ${} で埋め込んだ変数を展開してテスト対象URLを動的にできる。

{ "url": "http://localhost:3000/posts/${post_id}" }

post_id が 1 なら…
=>  { "url": "http://localhost:3000/posts/1" }

具体的な実装はシンプルにこんな感じ( backstop_data/interpolate.js )。

const variables = require("./variables");
const keys = Object.keys(variables);

module.exports = (json) => {
  const jsonStr = JSON.stringify(json);

  const interpolated = keys.reduce((acc, key) => (
    acc.split("${" + key +"}").join(variables[key])
  ), jsonStr);

  return JSON.parse(interpolated);
};

これを runner.js が呼び出して、BackstopJSに渡す前に適用している。

変数を展開するための値は、 backstop_data/variables.json に定義するようにした。

{
  "post_id": 1,
  ...
}

また、このJSONを手動で変更するのはつらいので自動で生成するスクリプトを書いた。 今回はRailsプロジェクトなので、次のようなRakeタスク。

desc 'Dump a JSON file for the backstop variable interpolation'
task dump_backstop_variables: :environment do
  post = Post.first!

  json = JSON.pretty_generate({
    post_id: post.id.to_s
  })

  File.open('backstop_data/variables.json', 'w') { |f| f.write(json) }
end

透過的なyarnスクリプトを用意する

次のように、yarnスクリプトを利用する場合でも、BackstopJSのサブコマンドやオプションを透過的に利用できるようにしたかった。

$ yarn run backstop test
$ yarn run backstop test --filter="<scenario label>"
$ yarn run backstop reference
$ yarn run backstop approve

JSONを結合したり、JSONに変数を展開したりといった機能を実現するために runner.js を作成したので、yarnスクリプトから引数を受け取って、うまいことBackstopJSの関数に渡す必要がある。 パーサをrequireするのも良いのだが、シンプルな用途なのに依存を増やすのもあれだったので、自分で軽く書いた。

backstop_data/runner.jsを参照。

ハマリポイント

スクリーンショットの撮影に失敗しやすい

テストレポートにスクリーンショットが出ない場合、撮影に失敗している。 サーバーが起動していて、かつ selector などの指定が明らかに正しい場合は、 backstop.jsonasyncCaptureLimit の値を下げてみると改善する。

この値はヘッドレスブラウザの並列起動数で、メモリが足りなかったりすると撮影に失敗することがあるようだ(詳しくは調べていない)。

他にも、サーバー立ち上げ直後だとアセットの更新(webpackだったり、Railsならアセットパイプラインだったり)で時間がかかりタイムアウトする場合がある。 事前にページを開いておく、webpackbin/rails assets:precompile しておくなどでテストを安定化できる。

デザイン崩れに起因しない差分が出やすい

テストの性質上、冪等性が保ちづらい。 デザインは崩れていなくても、スクリーンショットに差分が出ることが多くある。 例えば、

  • レコードの増減、変更
  • 時間経過による動的な表示
  • ランダム表示

などの影響を受けやすい。

あまりに動的で毎回差分が出るような要素はあらかじめ hideSelectors で非表示にすると良い。 もしくは特定要素のみの確認で良ければ selector でその他の要素の影響を避けることもできる。

差分が出て、デザイン崩れに起因しなければ、その都度 backstop approve する。