59
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

WACULAdvent Calendar 2016

Day 23

コンポーネント/単体テスト単位でのvisual regressionテストを行うためのツールを作った話し

Posted at

この記事は「WACUL Advent Calendar 2016」の23日目です。
今年の10月からWACULでフロントエンドエンジニアをしている@bokuwebと申します。

今回はコンポーネント単位でのvisual regressionテストについて書いてみたいと思います。

概要

単体テスト時にコンポーネントのキャプチャーを取り、差分を取るvisual regressionテストを行うために、karma-nightmarereg-cliというツールを作りました。まだ実験的ではありますが、それらを用いたテストや作成時に検討したことなどを書いてみます。

そもそもViewのテストどうしてます?

フロントエンドやっている方と話すときに、よく、Viewのテストをどこまで、どんなふうにやるか?というざっくりした質問をしてみたりします。「お、その話題ですか!」というリアクションする方もちらほらいて関心度は高そうなんですが、「これが決定版!」みたいな手法は聞いたことはなく、みんな工夫して頑張っているんだな、という印象をちょくちょく受けます。

Reactなどを使用しており多くのコンポーネントが冪等性を担保したコンポーネントであれば、smart componentstoreなどのstate生成部をしっかりテストし、Viewのテストは上手くサボるのもありだと思いますが、やはり、入力に対して複雑な分岐などが存在するコンポーネントなどはやはり単体テストでしっかり抑えておきたいところです。

現在Reactであれば、Enzymeなどを使用し、propsを与え「<Hoge />レンダリングされていること」のように、assertしていくのが一般的でしょうか。
しかしViewは変更が発生しやすい箇所ですし、やるにしても緩く最小限の手間で効果を上げたいという思いはみんな持っているんじゃないでしょうか。

すこし横道にそれますが最近Jestに搭載されたsnapshotテストはそういう意味では自分のやりたい方法に近いと感じました。

Jestsnapshotテスト

たとえば以下のような(react-create-appで生成した)コンポーネントに対して、snapshotテスト行ってみます。

snapshot testは、一度テストを実行するとスナップショットのファイルが作成されて、次回以降はスナップショットファイルとテストが一致するかどうかを判定され、一致していればテストにパスします。

  • comonent
my-app/src/App.js
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
my-app/src/App.test.js
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ファイルが保存され、次回以降このファイルとの比較が行われ、差分があった場合にテストが失敗するようになります。

my-app/src/__snapshots__/App.test.js.snap
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ファイルが更新され、次回以降テストの基準となります。

スクリーンショット 2016-12-20 8.45.21.png

スクリーンショットによる差分テスト

Jestsnapshotテストは多少目視による確認は必要としますが、ざっくりとViewのテストが行えるため、Viewのテストに対するコストは下がりそうです。

入力をざくっと与えて正となる出力を作っておけばOKですからちまちまAssertしていく必要は減りますし、変更があった場合のテストの修正も楽です。

ただ、この方法の場合、実際のブラウザ上での見た目がどうなっているかは分からないという問題があります。極端な例になりますが、誰かが、.App-intro { display: none }のようなcssを書いて、テキストが非表示になっていてもこのテストはpassし続けてしまいます。また、class名をtypoしていて実はスタイルが当たってない、なんてこともあるかもしれません。

この辺の見た目に関してはE2Eテストでキャプチャを取って確認するケースもあるかとは思いますが、E2Eのようにページ単位になるとどのコンポーネントが問題なのかわかりづらいですし、複雑な分岐をもったコンポーネントなどが合った場合、その条件をE2Eで網羅するのは現実的ではないしE2Eの責務ではありません。

前置きが長くなりましたが、上記のような経緯や考え方からコンポーネント単位で開発している場合は、やはり、コンポーネント単位で見た目も含めてテストしていきたい描画に差分が発生した場合はテストに失敗して欲しいという考えから、スクリーンショットによる差分テストが行えないかと考えました。(そもそも業務ではAngular v2を使っているのでJestが使えないですし・・。)

つまりは先に挙げたJestsnapshotテストの画像版のイメージです。1

単体テスト時のスクリーンショットを取る

まずは単体テストのスクリーンショットを取るためのkarma-nightmareというものを作りました。方法はいくつか考えたんですが、ひとまずこの方法に落ち着いています。2

karma-nightmarekarmaからnightmareを立ち上げてnightmare上でテストを実行するランチャーです。nightmareelectronのラッパーのなので、これでChromium上でテストを実行しつつ、capturePage APIを使用することでそのスクリーンショットも取ることができます。

もう少し手を加えたらpdfhtmlで保存することも可能です。

また副産物なんですが、karmaでのテストでrequireやその他nodeモジュールが使えるという利点もあります。

ここではReactを使って簡単なサンプルを書いてみます。
以下のリポジトリも合わせて参考にしてみてください。

src/index.js
import React, { Component } from 'react';

export default class HelloWorld extends Component {
  render() {
    return (
      <h1 style={{ color: '#fff', fontFamily: 'arial' }} >hello</h1>
    );
  }
}

上記のようなコンポーネントに対して、以下のテストを実施してみます。enzymemountし、karma-nightmarescreenshotを使用してスクリーンショットを取っています。

ちみにこのscreenshotですが、nightmare上で走っているときのみ実行されるので、他のブラウザchromephantomjsでは無視されるだけで、影響を与えることはありません。なのでこのテストコードはこのまま他のブラウザでも実行することができます。

ここではベタに画像ファイル名を指定していますがmochaではthis経由でspec名が取れるのでspec名 = 画像名などにしてやると良いかもしれません。(ここには挙げませんがJasmineでは残念ながらテスト名を取るには一工夫いるようです。)

test/helloworld.spec.js
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

hello.png

これでスクリーンショットを取る準備はできました。

スクリーンショットの差分比較を行う

次にスクリーンショットの差分比較を行うため、reg-cliというツールを作りました。

こいつは非常にシンプルなもので、取得した画像ディレクトリと、見本となる画像ディレクトリを指定してやると差分をとってhtmlレポートを吐くような作りになっており、Jestを踏襲して-Uオプションを付けて実行すると見本となるディレクトリに画像がコピーされ、見本が更新されるという作りになっています。

そのため、ReactAngularなどのコンポーネントのテストのみならず、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

スクリーンショット 2016-12-23 0.46.42.png

2. 画像に問題なければ見本となる画像更新する

画像に問題がなければ-Uをつけて再度実行します。これにより見本となる画像が更新され、テストに通るようになります。(この辺の手間をもう少し減らすように考えたいですね)

$ reg-cli ./docs/snapshot ./docs/expected ./docs/diff -R ./docs/report.html -U

./docs/report.html

スクリーンショット 2016-12-23 0.47.07.png

3. コンポーネントの内容を変更し再度テストしてみる

コンポーネントに何かしらの変更が発生したと仮定し、以下のようなテキストを変えてみます。

src/index.js
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に依存したコンポーネントが崩れていた。のような状況を避けることができます。

スクリーンショット 2016-12-23 9.06.00.png

課題

ここまでで一応はコンポーネント単位でスクリーンショットをとり差分の比較ができるようになりましたが、まだ次のような課題があります。

  • 画像を生成するマシンによって微妙に生成される画像が異なるケースが存在する4

画像差分比較側でしきい値を設けある値以下の差であれば同一画像とみなす。ということも可能ですがこれは抜本的な対策にはならないため(機能としては追加しても良さそうですが)、やはり、CIなどで画像を生成するマシンを固定し、管理する必要がありそうです。

そのための仕組みは今ないので、追って検討する必要がありそうです。

また、レポートの見た目や機能がいまいちなので随時更新していく予定です。

Angular v2でのサンプル

@Quramy が早速Angularでのサンプルを作成してくれたので紹介いたします。この記事のサンプルより、いい感じのサンプルなので是非合わせて見てみてください。

まとめ

Viewのテストでいかにサボりつつ効果をあげるかという考えをもとにツールを作り、使用してみました。まだまだ課題はありますが、ブラシアップしより良いものになった際は再度記事にしてみたいと思います。

この内容がテストの参考になれば幸いです。

最後まで読んでいただきありがとうございました。

脚注

  1. プルダウンのアイテムが動的に生成されているようなケースは画像より、生成されたDOMを見たほうがよく、ケースにより一長一短ではあります。

  2. PhantomJSであればキャプチャー機能を持っているので、最初はPhantomJSから試していたんですが、flexboxに対応していなかったり、見た目にいろいろ難があったのでやめました。また、karmamiddlewareを使って、endpointを叩かせ、nodeコンテキスト側でキャプチャを取ろうとも思いましたが、これも面倒そうでやめました。

  3. 余談ですが、Karma v1からcontextFileというhtmlファイルを設定することができるようになりました。これにより、工夫すればプロダクションの環境に近づけて単体テストを行うことができます。今回は次のようなcontext.htmlを使用しています。https://github.com/bokuweb/react-reg-test-sample/blob/master/test/context.html

  4. 同じmacにもかかわらず、同僚の作った見本画像に対して、自分のmacで作った画像を差分比較すると数ドットながら差分が発生する、ということがありました(原因は未追跡)。

59
32
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
59
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?