FOLIOの画像回帰テストの裏側

これは FOLIO アドベントカレンダー2018 の23日目の記事です。

さて、昨日の@minodiskの記事でも触れられている通り、FOLIOのWebフロントエンドではStorybookを中心に据えた画像回帰テストを実践しています。 この記事では、FOLIOに画像回帰テストを導入するためにやってきた取り組みについて書いていきます。

大まかなワークフロー

画像回帰テストのワークフローを簡単に説明します。

まずは対象となるコンポーネントについてStorybookでStoryを用意します。

Storybook

このStoryからCIで生成されるキャプチャ画像がスナップショットファイルとなります。

さて、仕様変更などによって上記のコンポーネントに変更が生じたとします。

コードをpushするとCIでStorybookから画像が生成され、クラウドストレージに保存されたスナップショットとの比較が走ります。

CIの様子

commitに対応するGitlabのMerge Requestに比較結果が自動でコメントされます。 ちなみに、赤丸が差分ファイルの画像数、青丸が差分のないファイルの数です。今日時点だと1,000枚弱の規模となっています。

コメントに付与されたリンクから、差分の詳細を閲覧できるようになっています。

このケースの場合、差分は生じているものの、Merge Requestに付与された説明などから意図的な変更であることはわかるため、差分が出ていても問題なしとして承認します。 Merge Commitによって再度CIが走り、そこで変更後の画像がスナップショットとしてクラウドにアップロードされます。

導入までの経緯

そういえば、僕は去年もアドカレで似たようなことを書いていました。 「お前いっつも似たような話して進歩ねーな」と思う人もいそうなので、言い訳を書いておきましょう。人間的に進歩してないのはそれはそうなのですが、去年との大きな違いとして、下記が挙げられます。

  • 利用しているフレームワークが変わった
  • 会社が変わった

画像の比較や差分抽出、比較レポートの生成など、ワークフローに関わる部分は reg-viz/reg-suit というツールを使っています。

reg-suitや、そこから呼び出されているreg-cliは、もともと前職であるWACUL在籍時に、メンテしていたSPAの品質を向上させるために当時の同僚と一緒に開発したものです。

最初はaws-cliやcURL、gitコマンドが入り乱れたシェルスクリプトだったのですが、画像回帰テストのワークフローを自社外の人達にも気軽に試してもらいたい、という思いからOSS化を進めた経緯があります。 汎用性に重きを置いたOSS化だったので、次の点を意識して開発しました。

  • 画像のみをインターフェイスとする。どのようなUIフレームワークによって描画されるかについては不可知とする
  • 動作環境を差し替え可能にしておく。SCM(GitHub or Gitlab or…)やCI製品(CircleCI or TravisCI or…)、利用するクラウドストレージ(AWS S3 or GCS or …)をPluginで変更できるようにする

ちなみに、前職であるWACULでは以下のスタックで開発をしていました。

  • Angular
  • GitHub
  • Wercker CI

これらを他のライブラリ・製品に差し替えた上でも、自分らで整備したOSSを再利用してワークフローを構築できるかどうかについては転職前から興味はあったわけですが、FOLIOという新天地はちょうどいい実験台でした。 FOLIOのフロントエンド開発スタックは次のとおりなのですが、これらは前職におけるそれとは別モノだからです。

  • React.js + Redux
  • Gitlab
  • Gitlab CI
FOLIOのフロントエンド開発環境

結論をいうと、reg-suitやreg-cliについてはほぼ何も変更せず、強いて言うならGitlab用のpluginをちょろっと作った程度で導入ができました。 時間がかかったのはむしろその前段の部分です。

  • そもそもReact Componentのテストを書く文化の醸成
  • 回帰テスト用の画像をCIで取得する部分

スナップショットを使った回帰テストと言えど、誰かが最初にそのスナップショットを作らないと何も始まらないわけです。 日々増えていくフロントエンドのコンポーネント開発の邪魔をせずに、これを実現することを考えると、やはりStorybookをプロジェクトに導入して、その旨味を享受させるのが最も手っ取り早かったです。 入社して最初の3ヶ月くらいは、画像回帰テストのことは一旦忘れて、Storybook書くと便利だよ!いいことがいっぱいあるよ!という布教活動に専念しました。

実際に画像回帰テストを運用開始したのは、今年の7月頭あたりからです。このタイミングを選択したのは、8月にサービスの正式リリースに伴う大幅なリブランディング対応を控えていたからです。

既存のReact ComponentやCSSの大量修正と、そのチーム内レビューの負荷を少しでも軽減すべく、このタイミングでぶちこむしか無い!と考えました。 それまでの3ヶ月の間にStoryの数もかなり蓄積された、というのもあります。この時点で400storyは超えていたと記憶しています。

リブランディングのときの差分が手元に残っていたので貼っておきます。

技術的な課題

画像回帰テスト導入の文脈で技術的な課題となったのは、CIでStorybookからキャプチャ画像を生成する処理における安定性と性能です。

この2つは本当に重要なのです。ちょっと想像してみてください。 あなたはUI Componentと関係の無いロジック修正をpushしただけなのに、回帰テストのCIに延々と待たされた挙げ句に身にいわれのない差分についてアラートされたら、最悪ですよね?

画像回帰テストに限りませんが、自動化の仕組みづくりについて、冪等性と性能は何を差し置いても担保しなくてはいけません。

Storybook + ブラウザオートメーションでスクリーンショットを取得するツールについては、既存のものが幾つか存在しています。

実際、FOLIOで画像回帰テストを導入した当初は車輪の再開発を避けるため、storybook-chrome-screenshotというツールを利用していたのですが、これらの課題に対応するために最終的には自分で別のツールを作りました。 それがzisuiです。自吸です。

zisuiの説明をする前に、そもそも具体的にどのような問題があったのかを先に述べておきます。

安定性の問題

ブラウザを自動でキャプチャしてスクリーンショットを取得する場合、ソースコードが同一なのに結果の画像が変化してしまう要因として以下が挙げられます。

  1. アニメーションが邪魔をする
  2. アセットの読み込み前にキャプチャが取得されてしまう
  3. レンダリングエンジンの描画完了前にキャプチャが取得されてしまう

1.のアニメーションについては、CSSであれば * { transition: none; !important } のような強制無効化を行うCSSをCIでのみ有効化することで回避できます。 Storybookの場合、.storybook/preview-head.html にこのようなCSSを追記することでコントロールできます。 フォリオにはCSSだけでなく、highcharts.jsによるJavaScriptアニメーションも存在しています。 こちらはCSSでは制御できないため、地道にwebpack define pluginで環境変数をhighchartsのconfigに伝播させて、スクリーンショット取得環境でのみアニメーションを無効化するようにしています。


2.のアセット取得追い越し問題について、もっともわかりやすいのはWebフォントなどのCSSから参照されるファイルです。 次の画像は、フォントの読み込み完了前後でスクリーンショットのキャプチャが行われてしまい、同一ソースコードで差分扱いされてしまった例です。

fontが見つからず、差分が出てしまう 😢

もちろん、十分長い時間を待ってからキャプチャを取れば回避できるのですが、それはCIの待ち時間の増大につながってしまいます。

現状ではPreload linkを利用することでこの問題を解決しています。

<link rel="preload" href="/font.woff" as="font" crossorigin />

のように書くと、ブラウザへ対してfont.woffファイルの先読みを指示できます。 また、Preload linkはそれぞれが onloadイベントを発火するため、リソースの取得完了タイミングを知ることもできます。

擬似コードで説明すると、次のようになります。

<script>
const emitter = new EventEmitter();
const assetPromise = new Promise(res => emitter.once('loaded', res));
function onload() {
emitter.emit('loaded');
}
</script>
<link rel="preload" href="/font.woff" as="font" crossorigin onload="onload" />

assetPromise の解決を待ってからキャプチャが実行されるように制御できればよいわけです。

実際のアプリケーションでは、利用されているアセットファイルを手で列挙していくのは現実的ではないため、ちょっとしたgulpのscriptを書いてpreview-head.htmlに上記のようなPreload linkを注入するようにしています。


3.として書いた、レンダリングエンジンの描画完了前にキャプチャされてしまうという問題について、もっとも顕著に顕れたのはレスポンシブなデザインのComponentをキャプチャする際でした。 モバイルWeb用のStoryとして定義していたはずのStoryがデスクトップ用のCSSスタイルが適用された状態でキャプチャされてしまう、といった具合です。 この件も、キャプチャのタイミングについて、十分長い待ち時間を設ければ頻度を下げることはできますが、CI時間とのトレードオフとなってしまいます。

また、対応が難しい理由として、JavaScriptでレンダリングエンジンのピクセルパイプラインにおけるPaintingやCompositeイベントの完了を検知する術が(僕の知る限りにおいて)存在しない、というのがあります。

この課題の解決については後述します。

zisuiがやっていること

安定性の課題として挙げた2.と3.は、キャプチャの取得タイミングの問題に帰着します。

  • 利用側がキャプチャ開始前に、ユーザー側で非同期処理の待ちを設定できる
  • 実際にレンダリングエンジンが落ち着くまで、さらにキャプチャを遅延させる

前者はそういうオプションを用意すれば終わる話なのでよいとして、問題は後者です。 こちらについては、場当たり的といってしまえばそれまでなのですが、Puppeteerのメトリクスを監視することでワークアラウンドとしています。

取得できるメトリクスのうち、以下の値の変動を見て定常状態に落ち着いてからスクリーンショットを取得するようにしています。

  • DOM要素数
  • layout発生回数
  • style再計算発生回数

直近の数フレームでこれらのメトリクス値が一定であれば定常とみなす、という実装です。


その他に性能を向上させる手段として次のように実装しています。 これら諸々のお陰で、zisuiの置き換え前にはCIで540枚のキャプチャに10分程度要していたところを、zisuiへ置き換えることで3分程度まで短縮を実現できています。

  • Puppeteer自体を複数並列起動してCPUをフル稼働させる
  • Storybookのプレビュー用iframeのロード回数は一度だけにし、webpack bundleの取得やコンパイルの回数を削減させる。Storyの切り替えはpostMessageだけで行うようにする

プレビューiframeの内側のみを利用する、というのは諸刃の剣でもあって、Addon パネルの情報が取得できなくなるというデメリットもあります。 例えば、StorybookのKnob Addonはstoryに与えるpropをUI上で変更できる機能ですが、このようなAddonを利用していたとしてもzisuiでその面倒を見ることはできません。 すなわち「Knobでxxxに値を変えた状態でスクショが欲しい」といったような要望があったとしても、zisuiは対応できません。

実際、storybook-chrome-screenshotからzisuiに移行する際にViewport Addonでこの問題が顕在化しました。 storybook-chrome-screenshotでは取得できていたViewport情報がzisuiからではAddonにアクセスできないために、storyのデフォルトViewportがわからなくなってしまうのです。 ただし、これについては解決はそれほど難しくありませんでした。

Storybookのデコレーターは所詮ただの高階関数ですので、addon-viewportが提供しているデコレーターと、zisuiが提供するデコレーターを合成し「zisuiにもaddon-viewportにもViewport情報を連携するデコレーター」を作ってしまえばいいのです。 高階関数同士を合成して新しい高階関数として定義する、というのはReact界隈だとrecomposeなどでよく見かけますね。あれと一緒です。 実際に使っている合成デコレーターは下記のとおりです。

import { withScreenshot } from 'zisui'
import { withViewport as withViewportFromAddon } from '@storybook/addon-viewport'
function getPuppeteerViewport(viewport) {
switch (viewport) {
case 'iphone6':
return 'iPhone 6'
default:
return null
}
}
export const withViewport = name => {
return function() {
const pv = getPuppeteerViewport(name)
if (!pv) {
return withViewportFromAddon(name)
}
const fn = withViewportFromAddon(name).apply(null, [arguments[0]])
const wrapped = withScreenshot({ viewport: pv, fullPage: false }).apply(null, [
ctx => fn(),
arguments[1],
])
return wrapped
}
}

作ってきたツール達の今後について

reg-suitやreg-cliなど、画像回帰テストのワークフローを担っている部分については、もはや大きな変更をしなくとも実際に使い物になるレベルまで達していると思っています。 実際、今年はたまにPluginを追加したり、PRをmergeしたりする程度で大きな改修は行っていません。 今後はreg-cliの出力するレポートのUI ブラッシュアップなどを進めていきたいです。

reg-suitがどれくらい使われているのか気になったので、折角なので調べてみました。次のグラフは、reg-suitのGitHubコメント連携の実行回数です。GitHub連携に対応するLambdaのCloudWatchから引っ張ってきました。

reg-suit がGitHubへコメントを行った回数

今年の8月頃から徐々に増えていますね。といっても多くても1日300回程度なので、もっと利用者が増えたらいいなーと思っています。土日になると極端に回数が下がるのは面白いですね。


一方、zisuiについては、実のところこれ以上メンテするかどうかを悩んでいます。 というのも、もともとはstorybook-chrome-screenshotを真似て作ったCIであり、zisuiの側は本家と違ってVue.jsやAngularの対応をちゃんとは行っていません。

zisuiでは、性能を優先するためにstorybook-chrome-screenshotがサポートしているAddon連携など幾つかの機能をdropさせたのですが、このあたりの妥協を受けて入れててもらいつつ、storybook-chrome-screenshot側へzisuiを統合させることも検討しています。

いずれにせよ、画像回帰テスト導入のハードルを下げるための活動そのものは今後も何かしらの形で続けていければと思っています。「自分の組織に導入したいんだけど…」などの相談なども気軽に乗っていくので、Twitter( https://twitter.com/quramy )でお声がけいただければと。

今後挑戦していきたいこと

なんだかんだで、いわゆるUIコンポーネントレベルでのテストについては相当量の知見も溜まってきたと思っています。

一方で、FOLIOのWebフロントエンド全体で見ると、まだまだテストが甘い箇所は多いと感じています。

特にBFFでマイクロサービス郡からデータを取得してSSRをおこなってHTMLを作る、といったようなスタック全体を通した機能の自動テストはほとんど書けていません。 ここに対してe2eまたはfeature testを拡充させていきたいわけです。

コンポーネント粒度での画像回帰テスト導入を何度かやってきて感じたのは、テストを書く文化を根付かせるためには、それがテストであること以上の恩恵を実装者に感じてもらうことがもっとも効果的だということです。 Storybookは一種のLiving Documentとなることにより、この恩恵を享受しやすいからこそ、わざわざ「テスト書け!」という言い方をしなくても導入できたわけです。

この成功体験のアナロジーをe2eにも持っていけないだろうか、といったことを最近はもやもやと妄想しています。 例えば、テストケースを通して取得された画面たちがそのまま最新の画面遷移図として共有できる、的なイメージ。

ということで、フロントエンドの仕組み作りに果敢にトライしてくれる仲間をFOLIOでは絶賛募集中です。

それでは、また。