この記事は「WACUL Advent Calendar 2016」の23日目です。
今年の10月からWACULでフロントエンドエンジニアをしている@bokuwebと申します。
今回はコンポーネント単位でのvisual regressionテスト
について書いてみたいと思います。
概要
単体テスト時にコンポーネントのキャプチャーを取り、差分を取るvisual regression
テストを行うために、karma-nightmare
とreg-cli
というツールを作りました。まだ実験的ではありますが、それらを用いたテストや作成時に検討したことなどを書いてみます。
そもそもViewのテストどうしてます?
フロントエンドやっている方と話すときに、よく、View
のテストをどこまで、どんなふうにやるか?というざっくりした質問をしてみたりします。「お、その話題ですか!」というリアクションする方もちらほらいて関心度は高そうなんですが、「これが決定版!」みたいな手法は聞いたことはなく、みんな工夫して頑張っているんだな、という印象をちょくちょく受けます。
React
などを使用しており多くのコンポーネントが冪等性を担保したコンポーネントであれば、smart component
やstore
などのstate
生成部をしっかりテストし、View
のテストは上手くサボるのもありだと思いますが、やはり、入力に対して複雑な分岐などが存在するコンポーネントなどはやはり単体テストでしっかり抑えておきたいところです。
現在React
であれば、Enzyme
などを使用し、props
を与え「<Hoge />
がレンダリングされていること
」のように、assert
していくのが一般的でしょうか。
しかしView
は変更が発生しやすい箇所ですし、やるにしても緩く最小限の手間で効果を上げたいという思いはみんな持っているんじゃないでしょうか。
すこし横道にそれますが最近Jest
に搭載されたsnapshot
テストはそういう意味では自分のやりたい方法に近いと感じました。
Jest
のsnapshot
テスト
たとえば以下のような(react-create-app
で生成した)コンポーネントに対して、snapshot
テスト行ってみます。
snapshot test
は、一度テストを実行するとスナップショットのファイルが作成されて、次回以降はスナップショットファイルとテストが一致するかどうかを判定され、一致していればテストにパスします。
- comonent
import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';
class App extends Component {
render() {
return (
<div className="App">
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h2>Welcome to React</h2>
</div>
<p className="App-intro">
To get started, edit <code>src/App.js</code> and save to reload.
</p>
</div>
);
}
}
export default App;
- test
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import renderer from 'react-test-renderer';
it('renders without crashing', () => {
const rendered = renderer.create(
<App />
);
expect(rendered.toJSON()).toMatchSnapshot();
});
テストを実行すると以下のようなsnapshot
ファイルが保存され、次回以降このファイルとの比較が行われ、差分があった場合にテストが失敗するようになります。
exports[`test renders without crashing 1`] = `
<div
className="App">
<div
className="App-header">
<img
alt="logo"
className="App-logo"
src="logo.svg" />
<h2>
Welcome to React
</h2>
</div>
<p
className="App-intro">
To get started, edit
<code>
src/App.js
</code>
and save to reload.
</p>
</div>
`;
その後、少しテキストを変更し再度テストを行うと、以下のように差分を表示し、テストが失敗しました。
差分を確認後問題なければ、u
オプションを付けてテストを再実行することによりsnapshot
ファイルが更新され、次回以降テストの基準となります。
スクリーンショットによる差分テスト
Jest
のsnapshot
テストは多少目視による確認は必要としますが、ざっくりとView
のテストが行えるため、View
のテストに対するコストは下がりそうです。
入力をざくっと与えて正となる出力を作っておけばOKですからちまちまAssert
していく必要は減りますし、変更があった場合のテストの修正も楽です。
ただ、この方法の場合、実際のブラウザ上での見た目がどうなっているかは分からないという問題があります。極端な例になりますが、誰かが、.App-intro { display: none }
のようなcss
を書いて、テキストが非表示になっていてもこのテストはpass
し続けてしまいます。また、class
名をtypoしていて実はスタイルが当たってない、なんてこともあるかもしれません。
この辺の見た目に関してはE2E
テストでキャプチャを取って確認するケースもあるかとは思いますが、E2E
のようにページ単位になるとどのコンポーネントが問題なのかわかりづらいですし、複雑な分岐をもったコンポーネントなどが合った場合、その条件をE2E
で網羅するのは現実的ではないしE2E
の責務ではありません。
前置きが長くなりましたが、上記のような経緯や考え方からコンポーネント単位で開発している場合は、やはり、コンポーネント単位で見た目も含めてテストしていきたい、描画に差分が発生した場合はテストに失敗して欲しいという考えから、スクリーンショットによる差分テストが行えないかと考えました。(そもそも業務ではAngular v2
を使っているのでJest
が使えないですし・・。)
つまりは先に挙げたJest
のsnapshot
テストの画像版のイメージです。1
単体テスト時のスクリーンショットを取る
まずは単体テストのスクリーンショットを取るためのkarma-nightmare
というものを作りました。方法はいくつか考えたんですが、ひとまずこの方法に落ち着いています。2
karma-nightmare
はkarma
からnightmare
を立ち上げてnightmare
上でテストを実行するランチャーです。nightmare
はelectron
のラッパーのなので、これでChromium
上でテストを実行しつつ、capturePage
APIを使用することでそのスクリーンショットも取ることができます。
もう少し手を加えたらpdf
やhtml
で保存することも可能です。
また副産物なんですが、karma
でのテストでrequire
やその他node
モジュールが使えるという利点もあります。
ここではReact
を使って簡単なサンプルを書いてみます。
以下のリポジトリも合わせて参考にしてみてください。
import React, { Component } from 'react';
export default class HelloWorld extends Component {
render() {
return (
<h1 style={{ color: '#fff', fontFamily: 'arial' }} >hello</h1>
);
}
}
上記のようなコンポーネントに対して、以下のテストを実施してみます。enzyme
でmount
し、karma-nightmare
のscreenshot
を使用してスクリーンショットを取っています。
ちみにこのscreenshot
ですが、nightmare
上で走っているときのみ実行されるので、他のブラウザchrome
やphantomjs
では無視されるだけで、影響を与えることはありません。なのでこのテストコードはこのまま他のブラウザでも実行することができます。
ここではベタに画像ファイル名を指定していますがmocha
ではthis
経由でspec名が取れるのでspec名 = 画像名などにしてやると良いかもしれません。(ここには挙げませんがJasmine
では残念ながらテスト名を取るには一工夫いるようです。)
import React from 'react';
import { screenshot } from 'karma-nightmare';
import { mount } from 'enzyme';
import HelloWorld from '../src/index';
describe('Hello', () => {
it('Should render hello', (done) => {
mount(
<HelloWorld />,
{ attachTo: document.querySelector('.main') },
);
screenshot('./docs/snapshot/hello.png').then(done);
});
});
実行すると以下のようなscreenshot
が取得できます。(背景は予めcss
で、マウントしている要素にあてられています。) 3
これでスクリーンショットを取る準備はできました。
スクリーンショットの差分比較を行う
次にスクリーンショットの差分比較を行うため、reg-cli
というツールを作りました。
こいつは非常にシンプルなもので、取得した画像ディレクトリと、見本となる画像ディレクトリを指定してやると差分をとってhtmlレポートを吐くような作りになっており、Jest
を踏襲して-U
オプションを付けて実行すると見本となるディレクトリに画像がコピーされ、見本が更新されるという作りになっています。
そのため、React
やAngular
などのコンポーネントのテストのみならず、E2E
で取ったスクリーンショットの比較や、生成画像比較したいケースなどにも使用できます。
実際に実行してみます。
1. 生成された画像に対してコマンドを実行してみる
先と同様、以下のリポジトリも合わせて参考にしてみてください。
今回はdocs/snapshot/
にスクリーンショットを吐くようなテストになっているので以下のコマンドを実行します。これで./docs/expected
を見本ディレクトリに、./docs/diff
を差分画像保存ディレクトリに、./docs/report.html
にレポートを吐くように動作します。
$ reg-cli ./docs/snapshot ./docs/expected ./docs/diff -R ./docs/report.html
以下のように新しい画像が検出されたメッセージとレポートが生成され、テストが失敗します。
❋ 1 new images detected.
✚ ./docs/snapshot/hello.png
Inspect your code changes, re-run with `-U` to update them.
./docs/report.html
2. 画像に問題なければ見本となる画像更新する
画像に問題がなければ-U
をつけて再度実行します。これにより見本となる画像が更新され、テストに通るようになります。(この辺の手間をもう少し減らすように考えたいですね)
$ reg-cli ./docs/snapshot ./docs/expected ./docs/diff -R ./docs/report.html -U
./docs/report.html
3. コンポーネントの内容を変更し再度テストしてみる
コンポーネントに何かしらの変更が発生したと仮定し、以下のようなテキストを変えてみます。
import React, { Component } from 'react';
export default class HelloWorld extends Component {
render() {
return (
<h1 style={{ color: '#fff', fontFamily: 'arial' }} >awesome</h1>
);
}
}
テストを実行します。
$ npm t && reg-cli ./docs/snapshot ./docs/expected ./docs/diff -R ./docs/report.html
意図通りテストが失敗しました。
✘ 1 test failed.
✘ ./docs/snapshot/hello.png
Inspect your code changes, re-run with `-U` to update them.
以下のようなレポートが吐かれています。ちょっと差分画像にはまだ工夫がいりそうですがコンポーネントに変更が入った際、テストが落ちるため、あるコンポーネントAを変更したら、知らず知らずの内の他のAに依存したコンポーネントが崩れていた。
のような状況を避けることができます。
課題
ここまでで一応はコンポーネント単位でスクリーンショットをとり差分の比較ができるようになりましたが、まだ次のような課題があります。
- 画像を生成するマシンによって微妙に生成される画像が異なるケースが存在する4
画像差分比較側でしきい値を設けある値以下の差であれば同一画像とみなす。ということも可能ですがこれは抜本的な対策にはならないため(機能としては追加しても良さそうですが)、やはり、CIなどで画像を生成するマシンを固定し、管理する必要がありそうです。
そのための仕組みは今ないので、追って検討する必要がありそうです。
また、レポートの見た目や機能がいまいちなので随時更新していく予定です。
Angular v2でのサンプル
@Quramy が早速Angularでのサンプルを作成してくれたので紹介いたします。この記事のサンプルより、いい感じのサンプルなので是非合わせて見てみてください。
まとめ
View
のテストでいかにサボりつつ効果をあげるかという考えをもとにツールを作り、使用してみました。まだまだ課題はありますが、ブラシアップしより良いものになった際は再度記事にしてみたいと思います。
この内容がテストの参考になれば幸いです。
最後まで読んでいただきありがとうございました。
脚注
-
プルダウンのアイテムが動的に生成されているようなケースは画像より、
生成されたDOM
を見たほうがよく、ケースにより一長一短ではあります。 ↩ -
PhantomJS
であればキャプチャー機能を持っているので、最初はPhantomJS
から試していたんですが、flexbox
に対応していなかったり、見た目にいろいろ難があったのでやめました。また、karma
のmiddleware
を使って、endpoint
を叩かせ、node
コンテキスト側でキャプチャを取ろうとも思いましたが、これも面倒そうでやめました。 ↩ -
余談ですが、
Karma v1
からcontextFile
というhtml
ファイルを設定することができるようになりました。これにより、工夫すればプロダクションの環境に近づけて単体テストを行うことができます。今回は次のようなcontext.html
を使用しています。https://github.com/bokuweb/react-reg-test-sample/blob/master/test/context.html ↩ -
同じmacにもかかわらず、同僚の作った見本画像に対して、自分のmacで作った画像を差分比較すると数ドットながら差分が発生する、ということがありました(原因は未追跡)。 ↩