PuppeteerとResemble.jsを使ったスクショ比較によるピクセル差分テスト

# やりたいこと
- Puppeteerによるクローリングでスクリーンショットを取得する。
- Resemble.jsを使って時系列による二地点の画像比較を行う。
- Resemble.jsは二つの画像の比較を行った上で、差分部分のピクセルに色を付けた比較画像を出力してくれるので、差分ピクセルが一定比率を超えた場合に、Jasmineによるテストが落ちるようにしたい。

# 背景
- **「１ピクセルを笑うものは、１ピクセルに泣く。」**
- Webメディアを運営している会社などでは、１ピクセル程デザインがずれただけで収益が大きく変動すると言うのもよく聞く話ですね。
- そこまでシビアな環境にいなくても、例えば、cssをちょこっといじりたい時に、他の画面のどこのデザインに跳ねてるのかが分からず、不安になったりしますよね。そのcssが長い年月をかけて肥大化していて、複雑怪奇に色々なところに当たっていたりするものだったりするとなおさらです。
- あとは、cssからsassなどの中間言語を導入したい時とかに、cssの変更を全くなくして導入するということも難しいと思うので、コードの側からだけでなく、スクショ側からも変更差分が分かると（コード変更前後のスクショを保存しておくと）、安心して「えいやっ！」でリリースするための一助となるのではないかと思います。

# どうやるか
- ただ、基本的に今やどこのサイトも動的なものばかりので、スクショを取って画像を比較したとしても、その間にデータの更新があったりすると、差分ピクセルが必ず発生してしまうものかと思います。
- ですので、画像が一致しているかどうかというテストをやってもあまり意味はなく、**①どれぐらいのピクセル数がずれているのかという実際の「 実差分比率 」が、②どれぐらいのピクセル数までならずれることを許容しても大丈夫そうかという「 差分許容率 」を超えていないかということをもってテストをした方が良さそうです。**
- おそらく、この実差分比率や差分許容率は、各画面のどれぐれらいの面積が動的なコンポーネントで占められているかによって変わってくるものか思いますので、画面ごとにその適切な水準も変わってくるものかと思います。 
- 次に、動的なページのスクショを時系列で作成して保存していく必要があります。
- なので、テスト用のページのチョイスをどうするか？という話になるのですが、情報通信研究機構が日本標準時を公開しているページがあり、こちらは１秒ごとに時刻が更新されています。何回かアクセスしてみると、「時刻情報取得状況」の表示の部分が「良好」「取得中」「不完全」とチカチカしているので、クローラーがスクショを取るタイミングによっては実差分比率が変わりそうなのため、テスト用としては良さそうですね。ということで、こちらのページを使うことにします。

情報通信研究機構：日本標準時
https://www.nict.go.jp/JST/JST5.html


# 前提
- `node.js` と `npm` が入っている。
- local の MBP（OSX 10.11.6）で実行する。


# 環境
| ツール  | バージョン | 説明 |
|:-----------------:|:------------------:|:------------------|
| node.js | 8.9.4　　　　|サーバーサイドでも使えるJavaScript　　　　　　　　　　　|
| npm | 5.6.0　　　　| node.jsのパッケージマネージャー　　　　　　　　　　　　　|
| puppeteer |1.9.0　　　　|ヘッドレスに（GUIからでなくても）Chromeを操作できるパッケージ　　　　|
| resemble.js |2.10.3　　　　|画像比較用のパッケージ　　　　|
| jasmine |3.2.0　　　　|テストするためのパッケージ　　　　　　　　　　　　|



# 手順
- インストール

```
$ npm i -D puppeteer resemblejs jasmine
$ npm i date-and-time fs-extra 
```
※ `date-and-time` は日付操作のために、 `fs-extra` はファイル操作のために入れておきます。

- テストコードの作成

[PuppeteerとJasmineを使ったE2Eテストの自動化](https://fancsdev.qiita.com/iHideck/items/8ce1848dac3fcaa3c535) の記事で作ったテストコードを少し修正して使います。

- ディレクトリ構成

テストを実行するごと（スクショを取るごと）に、`screen_shot` 配下にフォルダを増やしていきます。

```
$ tree
node
├── node_modules
│    └── （略）
├── package-lock.json
├── package.json
└── spec
│     ├── ViewSpec.js
│     ├── helpers
│     │     └── SpecHelper.js
│     └── support
│           └── jasmine.json
└── screen_shot
      ├── 2018_10_19_11:53:50
      │   ├── ViewSpec.png
      │   └── diff.png
      ├── 2018_10_19_11:58:43
      │   ├── ViewSpec.png
      │   └── diff.png
      ├── 2018_10_19_11:58:55
      │   ├── ViewSpec.png
      │   └── diff.png
      │            
```


- テストコードの作成（ ViewSpec.js）

```javascript
const URL = 'https://www.nict.go.jp/JST/JST5.html';
const date = require('date-and-time');
const fs = require('fs-extra');
const path = require('path');
const resemble = require("resemblejs");

describe("View", function() {

    beforeAll(async function() {
        // テスト（it）が複数ある時の前提で共通にした方がいい処理はこちらに書いておく
        global.now = new Date();
        await page.goto(URL);
    });
    
    it("has similar screenshot as before", async function() {
        const parentDir = 'screen_shot/';
        const fileName = path.basename(__filename, path.extname(__filename));

        // 一つ前のスクショのパスを取得
        const list = fs.readdirSync(parentDir);
        const previousFile = parentDir + list[list.length-1] + '/' + fileName + '.png';
            
        // 最新のスクショとディレクトリを作成
        const latestDir = parentDir + date.format(now, 'YYYY_MM_DD_HH:mm:ss').toString()  + '/';
        fs.mkdirsSync(latestDir);
        const latestFile = latestDir + fileName + '.png';
        await page.screenshot({ path: latestFile, fullPage:true });
        
        // 最新と一つ前のスクショを取得
        const imageBefore = fs.readFileSync(previousFile);
        const imageAfter  = fs.readFileSync(latestFile);

        let misMatchPercentage = 0;

        // 前回と最新のスクショの差分を比較
        resemble(imageAfter).compareTo(imageBefore)
            .ignoreColors()
            .onComplete(function(data) {
                fs.writeFileSync(latestDir + 'diff.png', data.getBuffer());
                console.log('前回スクショとの差分画像を作成しました。');
                console.log(data); // dataの中に差分画像生成時に色々なデータが入ってます。 
                misMatchPercentage = data.misMatchPercentage;
            });
        
        // 差分許容率はとりあえず１％にしておきます。
        expect(misMatchPercentage).toBeLessThan(1);
    });
});

```
- ヘルパーの作成（SpecHelper.js）

```javascript

const PUPPETEER = require('puppeteer');

beforeAll(async function() {
    // ViewSpec.jsが複数ある時の前提で共通にした方がいい処理はこちらに書いておく
    jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000;
    global.browser = await PUPPETEER.launch({
        headless: true
    });
    global.page = await browser.newPage();
    
    // Baisic認証が必要な場合
    // await page.authenticate({
    //     username: process.env.AUTH_USERNAME,
    //     password: process.env.AUTH_PASSWORD
    // });
});

```


# 実行してみる

`node` ディレクトリ配下で以下のコマンドを叩きます。

```
$ jasmine
```

## 成功した場合（差分許容率が１％未満場合）
実差分比率が0.08%と差分許容率内に収まっているのでテストが通ります。


```
$ jasmine
Randomized with seed 30485
Started
前回スクショとの差分画像を作成しました。
{ isSameDimensions: true,
  dimensionDifference: { width: 0, height: 0 },
  rawMisMatchPercentage: 0.3549698795180723,
  misMatchPercentage: '0.35',
  diffBounds: { top: 334, left: 377, bottom: 519, right: 437 },
  analysisTime: 79,
  getImageDataUrl: [Function],
  getBuffer: [Function] }
.


1 spec, 0 failures
Finished in 1.557 seconds
Randomized with seed 30485 (jasmine --random=true --seed=30485)
```

この時の画像を見てみましょう。

- 前回取得分のスクリーンショット
特に異常は見られませんね。
![1a692959-43f1-571f-c4e4-853721ce04a9.png](https://qiita-image-store.s3.amazonaws.com/0/106805/7d7b3b39-da92-33ba-0049-88ac8a1b592c.png)


- 今回取得分のスクリーンショット
こちらも特に異常は見られませんね。
 ![56fadb9e-2d04-d5be-a4f9-1eed4e875950.png](https://qiita-image-store.s3.amazonaws.com/0/106805/b01bdc61-13bf-2bfb-9ef1-adc9d001b3ff.png)

- 差分画像
差分部分のピクセルがピンクの色で表示されています。
![c278c6ed-3ada-64f8-5f1e-3c62d44746b3.png](https://qiita-image-store.s3.amazonaws.com/0/106805/0236fd93-25b9-a401-ea0c-56c832264927.png)


##  失敗した場合（差分許容率が１％以上場合）
実差分比率が1.68%と差分許容率内に超えているのでテストが落ちてます。

```
$ jasmine
Randomized with seed 36037
Started
前回スクショとの差分画像を作成しました。
{ isSameDimensions: true,
  dimensionDifference: { width: 0, height: 0 },
  rawMisMatchPercentage: 1.6831325301204818,
  misMatchPercentage: '1.68',
  diffBounds: { top: 246, left: 239, bottom: 550, right: 506 },
  analysisTime: 45,
  getImageDataUrl: [Function],
  getBuffer: [Function] }
F

Failures:
1) View has similar screenshot as before
  Message:
    Expected '1.68' to be less than 1.
  Stack:
    Error: Expected '1.68' to be less than 1.
        at <Jasmine>
        at UserContext.<anonymous> (/Users/hi_ishikawa/nex8.net/nex8/core/node/spec/fan/agencies/ViewSpec.js:45:36)
        at <Jasmine>

1 spec, 1 failure
Finished in 1.304 seconds
Randomized with seed 36037 (jasmine --random=true --seed=36037)

```
この時の画像を見てみましょう。

- 前回取得分のスクリーンショット
特に異常は見られませんね。

![9f22f5da-4df5-db49-2a2b-eb63802a1d10.png](https://qiita-image-store.s3.amazonaws.com/0/106805/0b000632-1d73-3184-ff10-2a4fffc69496.png)

- 今回取得分のスクリーンショット
時刻が取得できていません。おそらくこのページが時刻を読み込む前に先にクローラーがスクショを取ってしまったのでしょう。

![befe1f33-a7ed-721d-3655-a8b08cd26bd7.png](https://qiita-image-store.s3.amazonaws.com/0/106805/04f5e831-4c6f-afb6-305f-63ce5a5853eb.png)

- 差分画像
確かに目視でも全体占める差分ピクセルは１％を超えてそうな感じはありますね。

![92e736dd-09dc-8798-9bdd-c33c8d8d85a5.png](https://qiita-image-store.s3.amazonaws.com/0/106805/9e27be81-b3cd-fb2b-1825-9c400f8d40e7.png)

なお、本来は、クローラーがページの読み込みを待ってからスクショを取るように実装すべきですが、今回はそういったことを便宜的に利用して、敢えてエラー扱いとした次第です。

# やってみて
- 画面ごとに適切な水準の差分許容率を設定することは、ギリギリ真面目にやるとすると、実差分比率を記録しておいて、標準偏差を取って、どのぐらいの区間だったらとか確率的に発生得る値かとか、を見てから決めないといけないので、かなりめんどくさいことになりそうだと思いました。それをやらないとすると、何回か結果をみて、「こんぐらいだろ」とテストの実装者による感覚的な設定になっちゃいそうです。
- それと、エラーのスクショを二回連続で取ってしまった場合は、差分が少なくなるのでエラーなのにテストが通ってしまうということも発生し得るので、うまいこと仕組みを考えないといけないと思います。
- と言う具合に、割と簡単に実装できる一方で、運用面の課題の方が大きそうだなといった印象を持ちました。
- なお、今回は、実際にプロダクトに適用したものを紹介するのではなくて、「こうゆうこともできるよね」と言う着想から、とりあえず動くプロトタイプを作って見たという話になります。

# 参考
[Resemble.js](https://github.com/HuddleEng/Resemble.js?files=1)