1日10万枚の画像を検証するためにやったこと


お前は今までスクショした画像の枚数を覚えているのか?

こんにちは。WACULでフロントエンドエンジニアをしている @Quramy です。

冒頭のやつは書いてみたかっただけです。気にしないでください。ちなみに僕はDIOよりも吉良吉影派です。

11月末に、Node学園祭で Introduction to Visual Regression Testing というLTをさせて頂きました。

この時は大分話題を絞っての発表でしたので、今日は弊社で実施しているフロントエンドの画像回帰テストについて、LTでは割愛した部分も含めてヌルっと書いていこうと思います。

そもそも、WACULのアドベントカレンダーでこのネタを書くのはこれが初めてではありません。
2016年にも、 @bokuwebコンポーネント/単体テスト単位でのvisual regressionテストを行うためのツールを作った話し で、画像回帰テストを取り扱っています。

snapshotテストは多少目視による確認は必要としますが、ざっくりとViewのテストが行えるため、Viewのテストに対するコストは下がりそうです。
入力をざくっと与えて正となる出力を作っておけばOKですからちまちまAssertしていく必要は減りますし、変更があった場合のテストの修正も楽です。 (中略) 描画に差分が発生した場合はテストに失敗して欲しいという考えから、スクリーンショットによる差分テストが行えないかと考えました。

そして、彼は次の結びを残しました。

まだまだ課題はありますが、ブラシアップしより良いものになった際は再度記事にしてみたいと思います。

あれから1年、うちのフロントエンドチームで色々と試行錯誤を行ってきました。折角ですので、この場を借りてアンサー記事を書いてみた次第です。

到達した状態

さて、現状で僕らの現場では以下のワークフローを達成できています。

  1. メンバがGitHubにトピックブランチをpushする
  2. CIで単体テストが実行されると同時にスクリーンショットが取得される
  3. スクリーンショットの画像をスナップショットとして、回帰テストが実行される
  4. GitHubのPRコメントとして、差分有無が届く
  5. レビュアは差分レポートを元にレビューを実施
  6. 変更が問題ないと判断されれば、masterへmergeされる

PRコメントの例

差分レポート例

3, 4の部分については、昨年のアドカレで紹介したreg-cliというツールに改善を加え、reg-suitというツールをリリースしています1

全体像を図で示すと、下図になります。

図にあるように、弊社のプロダクトはAngularによる中〜大規模のSingle Page Applicationです。
2017年12月現在でのプロダクト規模はおよそ次のとおりです。

  • コード量(SPA部分のHTML + CSS + TypeScript): 約160,000 locs
  • Angular コンポーネント数: 約600個
  • テストケース数(単体): 約1,600ケース
  • テストで取得される画像の枚数: 約800枚

メンバーがpushするたびに、CIで単体テストが周り、800枚の画像が生成され、これらが全てreg-suitによって回帰テストにかけられます。1日に10数回程度はGitHubへpushが行われているとすると、検証される画像枚数が100,000枚を超えることも珍しくありません。

この一連の処理が数分で、そして誤検出や検出漏れといった事故も殆どなく回っているのです。控え目に言っても割と最高の環境だと自負しています。

何が最高なのか

特に下記の点が気に入っています。

  • 意図せずに見た目が破壊されない
  • レビュアの負荷軽減
  • フロントのテストコードを書く動機づけ

一点目は言わずもがなでしょう。元々bokuwebが去年のアドカレで言及してくれた点でもあります。

ただ、「意図せぬ破壊」に出くわす頻度は高くありません。一ヶ月に一度あるかないかといったところです。しょっちゅう壊していたら、それはそれで問題ですし。

むしろ、太字で強調した部分、すなわち、画像を使ったチームのワークフローそのものがとても気に入っています。

回帰テストに書けられる画像はすべてAWS S3へアップロードされています。
自分がレビュアにアサインされたとき、ローカルのレポジトリをstashに退避、PRをcheckout、ビルドして起動して目で確認して、、、なんていう作業をする必要から解放されます2
なんなら通勤中の電車内からスマホで見た目をチェックしてmergeボタンポチー、とかできます。
また、非エンジニアにもmerge前に変更後の見た目についてレビューを依頼することも容易になりました。
ここ にデモレポジトリを用意しているので、是非実物を見て雰囲気を感じ取ってください。

逆に、自分がPRを作る側になったときも、「テストコードを書く」という行為と「ビジュアルを確認する」という行為が密に連動するため、テストを書くことに対する心理的な障壁が格段に下がったと思っています。画面を用意しているうちにテストコードになっていた、的な感覚です(後述)。

実は、以前の案件では、View層のテストコードを書く習慣が身につかず、「余裕があればテストしたいんだけど中々ペイしないんだよね」という言い訳をしていました。
ちゃんとした仕組みを作るとちゃんとテストが回るという成功体験が得られたのは大きいです。
堂々と「テスト書いてないとかお前それt-wadaの前でも(以下略」のAAを発表資料で使えるようになりました。

reg-suitというツールについて

上記で紹介した開発ワークフローそのものは今年の4月頃には完成していました3
この頃は、bokuwebのアドカレにあるkarma-nightmare + reg-cliを使いつつ、AWS周りやGitHub API周りの足りない部分はシェルスクリプトで乗り切っていました。

しかし、去年末に想像していた以上に、このシェル部分が複雑化しており、「このままでは他の人に勧めようにもあまりも障壁が高い」という想いと、たまたまその時期に開発合宿や入院などのイベントが重なってコーディング時間を確保できたために、一気にツール化を進めて完成させたのがreg-suitです。

本当はこの記事で使い方を解説しようと考えていたのですが、下記のblogで導入方法を取り上げて頂いているので、興味がある方はこちらを参照ください。どちらもとても丁寧に書いてくれています。ありがたや。

reg-suitの設計思想についても、下記のように書いてもらっています。

スクリーンショットの取得方法は、プラットフォームやFW、テスト方法など、いろいろな方法があります。reg-suit はそこには関与せず差分チェックに特化することで、どんなWebサービスやネイティブアプリにも適用できる汎用性を持っています。

本当にその通りです。何も言い残すことがない。

なぜ仕組みがうまく回っているのか

ということで、ここからは回帰テストの仕組みをちゃんと回すことができたのか、その要因分析をしてみようと思います。

僕はこの1年、何度か勉強会やカンファレンスでフロントエンドのテストの話をしてきました。
すると、懇親会などで色々と質問してもらえます。大概は「大変だったんでしょう?そしてお高いんでしょう(工数が)?」的なニュアンスが多かれ少なかれ混じっています。

確かに簡単、ということは決して無いのですが、多分ですけど質問してくる方が想像してるほどの大変さはないです。

以降では、テストにまつわるTipsを紹介していきますが、狙って実施したものもあれば、全然別の文脈の都合で実施していて、今思い返すと役立ったものもあります。

E2Eではなく単体テストであること

画像で回帰テスト、というと、どちらかというとSeleniumなどでアプリケーションを操作しつつ、その過程をキャプチャしておいて、前回との比較を行う、というフローを想像されるかもしれません。
うちのプロダクトでは、特にE2Eはやっておらず、飽くまで「単体テストの一環として画像を利用している」というスタンスです。

今にして思うと、このスタンスが良かったように感じています。
理由は2つです。

1つ目は、E2Eの場合、どうしてもスクリプトやAPI側の準備など、事前準備が大仰になりがちで、かならずしも全てのコンポーネントの見た目がキャプチャーされるわけでもないので、今ほどの安心感は得られなかった可能性があります4

2つ目に、画面全体をレンダリングするという都合上、どうしても毎回毎回同じ画像にする、というのが難しいのではないかという懸念があります。
例えば、現在の時間を表示する機能があったとすれば、その部分が差分になってしまいますし、ランダムに広告やオススメの商品が表示されるようなコンポーネントが組み込まれていても同様です。
同じソースコードからは同じ結果が得られることを担保するのはスナップショットテストの肝です。ここが守られなくなると、そもそも誰もその仕組を信用しなくなってしまいます。
その点、単体テストであれば、ランダム性がある部分はモックに差し替えるなどの対処がし易いと考えています。

Dumb Component

FluxやReduxに代表されるアーキテクチャにもとづいてアプリケーションを作っていくと、大半のコンポーネントはStateの写像になっていきます。

言い換えると「DOMの出力を決定するのに必要なのはそのViewのpropsだけだ」という思想のもとにコンポーネントを作っていくことになります。
もちろん、全てのコンポーネントが純粋な関数として定義できるわけではないです。必要に応じて、DIやコンポーネント内での状態制御は記述します。
重要なのは、関数を作ろう!という姿勢でコンポーネントを開発すると、テストコードの書きやすいコンポーネントになっていく、という点です。

僕らは単体テストと言いつつも、Shallow Rendering5 ではなく、Integration Testingの形式でテストケースを書くことが多いのですが、下層のコンポーネントがステートレスに動作するようにしておけば、コンテナに近い側のコンポーネントであっても、一意なpropsを与えれば、一意なDOMが手に入ります。
テスタビリティの高い設計をしようね、と言っているだけで、「何を当たり前のことを」と思うかもしれませんが、画像を使う・使わないによらず、この開発スタイルは上述の「同じソースコードは同じ結果」を達成する上で重要です。

型付き言語でモックデータが書きやすい

単体テストを実行する以上、テストケースの実行時には、引数を記述する必要があります。
テストコードを記述する上で、型付けされた言語が果たす役割はとても重要です。

例えば以下であらわされるコンポーネントを作ったとしましょう。

type UserProps = { name: string, age: number };
function User(props: UserProps) => React.Element;

このコンポーネントに相当するテストコードは下記のようになるでしょう。

test('User component should be rendered', t => {
  const input: UserProps = {
    name: 'Quramy',
    age: 18,
  };
  renderer.render(<User {...input} />, domElementToBeMounted);
  screenshot('user_component_should_be_rendered.png');
});

screenshot というのは、renderされた状態を画像にするメソッドと思ってください。画像回帰テストなので、画像さえ吐いてしまえば後はそれを元にテストの成功可否が決まるので、細かいアサーションは記述しません。

一方で、以下の部分、テスト対象に対する引数は自分で書かねばなりません。

  const input: UserProps = {
    name: 'Quramy',
    age: 18,
  };

実案件ではもっと複雑なオブジェクトになるため、これを書くのは結構ダルい作業なのですが、TypeScriptやFlowで引数の型が決められていると、エディタによる補完がバシバシ効くので、すらすらとモックデータを書いていくことができます。
逆に構造を間違ったら、コンパイルエラーになるのでテストを走らせるまでもないです。

この話も特に画像やコンポーネントに限った話では無いのですが、「テストコード書くのダルい」という気持ちを最小化するが重要です。

さらに言うと、テストコードにおける雛形部分はツールに生成させるとより楽ができます。
例えば @angular/cli はコンポーネントを作成すると、自動でテストコードの雛形を生成します。
自分でそういったツールを作るのも簡単です。mustacheやinquire, minimist辺りを組み合わせれば、プロジェクトに合わせたスキャフォルディングコマンドを用意するのは難しいことではありません。

ローカルでテスト結果を確認できる仕組み

ここまでテスト、テストと連呼してきましたけど、実はそもそもテストを書いているという意識があまり無いです。

「コンポーネントを作ったからテストコードを書かねば」というよりも「コンポーネントを開発する手段として、それが最も効果的であるからテストコードを書く」という気持ちの方が近いです。

例えばKarmaの場合、テストケースを書いて手元でKarmaのデバッグ画面を立ち上げれば、そこにコンポーネントが描画されています。
開発者ツールも使えるし、CSSも調整可能です。

アプリケーション全体を立ち上げるよりも手軽ですし、上で書いたようにモックデータを調整すれば、本番では目にかかれないようなレアな異常系状態の作成も容易です。

弊社ではデバッグ画面を起動しつつ、モックデータとテストコードを用いて画面開発をすすめるスタイルのことを「KDD(Karma Driven Development; カルマ駆動開発)」と呼称しています。おかげで業を深めることができました6

別にKarmaやBDDライクなテスティングフレームワークへのこだわりはどうでもよく、最近はStorybookがReactだけでなくVue/Angularにも対応しているので、こちらを使うというのも良さそうです。

CIの実行時間を抑える

「めっちゃCIの時間かかりませんか?」と質問されることも多いです。
わかりますよ、その気持。たしかにpushするごとに何10分も待たされるのは勘弁願いたいですもの。
多分、質問される方が想像しているよりもはるかに短時間にさばくことができます。

下のグラフは、緑色の縦線(左軸)にCIでのテスト実行時間[mesc]、青色の折れ線(右軸)にmasterのコード量[locs]をプロットしたものです。

最近はおよそ3分程度で推移しています。この内訳はだいたい以下のようになっています。

  • テストコードのビルド(トランスパイル + webpackによるバンドル): 約30秒
  • 単体テストの実行: 約150秒
  • 差分比較 + S3へのアップロード: 約20秒

何も考えずにやると、確かにすぐアホみたいに時間がかかるようになってしまいますが、色々手の打ちようがあります。
幾つか高速化のテクニックを紹介します。

遅いブラウザを使わない

ヘッドレスブラウザといえばPhantomJS、という発想は捨てましょう。
PhantomJSは FlexBoxすらまともにレンダリングできないですし、すぐにハングアップしてしまうため、2017年にもなって使用する理由がまったくありません。

うちのプロジェクトでは、元々はPhantomJSでしたが、やはり性能的な問題を契機に、Nightmareベース(karma-nightmare)の環境に乗り換えました。
10%~20%程度、実行時間を削減することができましたし、何よりもブラウザが途中で止まってしまってCIがタイムアウトする、といったケースが撲滅されました。

ちなみに karma-nightmareも弊社メンバー製ですが、これ以外にもElectronベースのテストランナーとして以下を開発しています。

  • avaron: Electronを実行基盤としつつ、AVAのAPIが使える子
  • nirvana-js: Electron製の汎用スクリプトランナー。JasmineやMochaをブラウザで実行しつつ、Node.jsで制御できる

Node.jsのAPIを使いつつDOMも欲しい、といったケースでは有用です。これはダイレクトマーケティングというやつです。

また、最近では、Chromeに --headless オプションが導入され7、xvfb(X仮想フレームバッファ)を導入しなくともCIでChromeを動作させることが可能となりました。
CircleCI 2.xやWercker CIのように利用するDockerイメージを自由に指定できるサービスも増えてきましたが、テスト用のイメージサイズの削減にもつながります8

karmaであれば、karma-chrome-launcherがheadlessオプションをサポートしています。
また、PuppeteerのようなChrome Devtool Protocolクライアントを使えば、Node.jsでブラウザを操作しつつ、MochaやJasmineのようなテストフレームワークを実行可能です。要するにKarama相当のテストランナーは自作できる、ということです9

CPUをフルに使う

ブラウザでJavaScriptを実行する以上、何も手を打たなければUIスレッドに負荷が集中します。すなわち、マルチコアCPUであっても1コアにロードが偏ります。

例えば、Node.jsのテストフレームワークであるAVAは動作環境のCPUコア数に応じて、自動的に複数プロセスをspawnしてテストを並列実行する、といった工夫がなされています。
これと同様のテクニックをブラウザベースのテストに導入することで、CIでのテスト実行時間を削減可能です。

偏りが生じないようにテスト対象を分散させる、などの手間はかかるので、プロジェクトの規模がある程度に達してから実施すればいいと思いますが、かなり効果があります。具体的には、WerckerCIにて、並列化の実施前後で、テスト時間が60%程度削減されました。

ちなみに、先程紹介したavaronやnirvana-jsは複数のElectron Rendererプロセスを起動し、それぞれに別のテストファイルを渡せるように設計されています10

setTimeout使わない

たまに「ブラウザのレンダリングをキャプチャ取得が追い越すので、100ミリ秒待つようにしています」という話を聞きます。実際、うちのプロダクトでも一時期やっていました。

しかし、800枚画像があると、1ケースあたりは100msecでも、トータルしたら1分以上が待ち時間として加算されます。
実DOMを使っている以上、待ってる間に他のテストケースのレンダリングをさせるわけにもいきません。

描画を待ちたいのであれば、requestIdleCallbackを使った方がよいです。

こんなイメージ
it('shoud be rendered', (done) => {
  render();
  requestIdleCallback(() => screenshot().then(() => done()));
});

https://www.w3.org/TR/requestidlecallback/ より

requestIdleCallbackに登録したコールバックは、レンダリングなどのUIタスクを待ってから実行されるため、描画が終わってからスクリーンショットの取得が実行されます。
一瞬で描画が終わるケースであれば、無駄に100msec待つ必要もありません。

ただし、テストケースの中で断続的にUIタスクが発生するような状況なってしまっていると、やはりスクリーンショット取得が予期せぬタイミングで動作する原因となります。
CSSアニメーション等などはテスト時には抑止しておくとよいです。

コンポーネントは事前にコンパイルする

これはAngularに限った話です。[Angular] AoT Compile結果を単体テスト時に利用する に書いた件なので、詳細は割愛します。

おわりに

なんだかんだで取り留めのない内容になってしまいましたが、如何でしたでしょうか。

結局のところ、言いたかったのは「画像回帰テストをやってみたら良かったし、色々とノウハウも溜まったので、是非試してみて」です。

あと、reg-suitにフィードバックやPRをくれると喜びます。「英語メンドイ...」って思うのであれば、Twitterとかに日本語でくれても大丈夫です。

それでは、また。


  1. 正確にはreg-cliも残しています。reg-suitのコア機能がreg-cliである、という住み分けをしています 

  2. もちろんローカルで動作させる事も多いです。アニメーションの出来が気になるときや、手厚くレビューしたい場合はちゃんとローカルチェックアウトします 

  3. 2017年のng-japan発表資料 でも少し触れています。そもも、ここで「OSS化したい」と宣言したことが、やらねば!というプレッシャーとなって結果的にreg-suitが生まれた、というのが正しいです 

  4. とはいえ、ちゃんとE2Eを回したことがあるわけでもないので、「そんなことないよ!」という意見があればコメントやTwitterで教えてください。情報交換しましょう。 

  5. Shallow Renderingは、テスト対象のコンポーネントのみの描画を行います。一方、Integration Testingは対象のコンポーネントが配下で呼び出す子コンポーネントの描画も行う手法です。 

  6. なんなら実は今はKarma自体は使っていないのですが、「テストコードで画面開発する」という意味で「カルマ駆動」と言っています。概念だけが生き残った 

  7. 本家のChromeが公式にheadlessで動作するようになったからこそ、PhantomJSのメンテナンスが停止された、というのもあります 

  8. Headless Chrome on Dockerには、lighthouseのDockerfileを使うか、xcbを好みのベースイメージにインストールすると良いです 

  9. https://github.com/Quramy/angular-puppeteer-demo にAngular CLIプロジェクトのテストランナーをPuppeteerに置き換えるデモを用意しています 

  10. というより、nirvana-jsはうちのプロジェクトで利用するテストランナーをkarmaから置き換えるために、僕が開発したOSSなのですが、完成していざプロダクションに適用しよう!というタイミングでPuppeteerがリリースされたことで、結局実運用されなかったという悲運の子です。