スクリーンショットの差分を可視化する

Nightmare + GraphicsMagick + AVA

Nightmare + GraphicsMagick + AVA でUIテストの差分を可視化する

はじめに

はじめまして。レストランボードのフロントエンド開発を担当している山口です。

レストランボードのフロントエンド開発では、Nightmare と GraphicsMagick を利用して、UIの変更を差分画像として可視化するようにしているので、今回はその方法をご紹介します。

背景

レストランボードでは、コンポーネント毎のHTMLモックがあり、データパターンを複数用意して、UIの確認を行っています。

しかし、人間がブラウザを操作して1つずつ目視で漏れなく確認することは現実的に不可能であり、日々の機能追加で増えた260超のパターンの確認作業がかなりの負担になっていました。

影響範囲が特定されている場合はまだ良いのですが、共通化されたJSやCSSを変更した場合には影響範囲がどこまで及ぶか把握することが出来ず、デグレードの発生を見逃しあわや大惨事!ということもあったため、UI確認の自動化の仕組みを導入することにしました。

利用技術

Nightmare

Nightmare はブラウザの挙動を自動化するライブラリです。

v1では PhantomJS のラッパーでしたが、v2からは Electron 製になったことで、2倍高速化しているようです。

モックの各ページにアクセスしてスクリーンショット画像を撮る部分に利用しています。

GraphicsMagick

GraphicsMagickImageMagick からフォークした画像処理システムです。

Nightmareで撮影したスクリーンショットの差分確認し比較画像の出力に利用しています。

AVA

AVA は次世代のテストランナーです。

Node.js のノンブロッキングI/Oの性質を利用して、テストを並列に実行することで、Mocha 等のテストランナーよりもテストにかかる時間を大幅に短縮することが可能になっています。

Nightmare と GraphicsMagick を使うためのNPMモジュールが公開されているのと、各ページの差分の有無をテストレポート風に確認したかったため、 AVA で実行する様にしています。

実行方法

1
BASEURL='stable版モックのURL' CHANGEURL='開発中のローカルURL' ava ui/diffTest.js

比較対象のURL等を環境変数に指定して実行します。

以下が実際のテストコードです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
import Nightmare from 'nightmare';
import gm from 'gm';
import mkdirp from 'mkdirp';
import path from 'path';
import test from 'ava';

// host設定
const baseUrl = process.env.BASEURL;
const changeUrl = process.env.CHANGEURL;

// 対象ページ一覧取得
const pages = ['対象ページの配列'];

// ディレクトリ設定
const rootDir = path.resolve('.tmp/screenshot');
const baseDir = path.resolve(rootDir, './base');
const changeDir = path.resolve(rootDir, './change');
const diffDir = path.resolve(rootDir, './diff');

// ページ単体テスト本体
pages.forEach((page) => {
  // 負荷が高いため、並列ではなく、直列実行
  test.serial(`ui/diff#${changeUrl}/${page}`, (t) => {

    // ページディレクトリ 設定
    const basePath = path.parse(path.resolve(baseDir, `./${page}`));
    const changePath = path.parse(path.resolve(changeDir, `./${page}`));
    const diffPath = path.parse(path.resolve(diffDir, `./${page}`));
    const baseImg = `${path.format(basePath)}.png`;
    const changeImg = `${path.format(changePath)}.png`;
    const diffImg = `${path.format(diffPath)}.png`;

    [basePath, changePath, diffPath].forEach((val) => {
      mkdirp(val.dir);
    });

    // nightmare オプション設定
    const nmOptions = {
      switches: {
        'ignore-certificate-errors': true
      }
    };
    // graphicsmagick オプション設定
    const gmOptions = {
      file: diffImg,
      highlightStyle: 'xor',
      tolerance: 0
    };

    // screensize 取得
    const getScreenSize = () => {
      return new Promise((resolve) => {
        const body = document.querySelector('body');

        resolve({
          height: body.scrollHeight,
          width: body.scrollWidth
        });
      });
    };

    // base 画像出力
    const outputBaseImg = () => {
      return new Promise((resolve, reject) => {
        const nightmare = Nightmare(nmOptions);
        nightmare
          .on('page', (type, err) => {
            reject(new Error(err));
          })
          .goto(`${baseUrl}/${page}`)
          .wait(5000)
          .evaluate(getScreenSize)
          .then((dimensions) => {
            return nightmare
              .viewport(dimensions.width, dimensions.height)
              .screenshot(baseImg);
          })
          .then(() => {
            nightmare.end(() => {
              resolve();
            });
          })
          .catch(reject);
      });
    };

    // change 画像出力
    const outputChangeImg = () => {
      return new Promise((resolve, reject) => {
        const nightmare = Nightmare(nmOptions);
        nightmare
          .on('page', (type, err) => {
            reject(new Error(err));
          })
          .goto(`${changeUrl}/${page}`)
          .wait(5000)
          .evaluate(getScreenSize)
          .then((dimensions) => {
            return nightmare
              .viewport(dimensions.width, dimensions.height)
              .screenshot(changeImg);
          })
          .then(() => {
            nightmare.end(() => {
              resolve();
            });
          })
          .catch(reject);
      });
    };

    // diff 画像出力
    const outputDiffImg = () => {
      return new Promise((resolve, reject) => {
        gm.compare(baseImg, changeImg, gmOptions, (err) => {
          if (err) {
            reject(err);
          } else {
            resolve();
          }
        });
      });
    };

    // 実行
    return Promise.all([outputBaseImg(), outputChangeImg()])
      .then(outputDiffImg)
      .then(() => {
        t.pass();
      })
      .catch((err) => {
        t.ifError(err);
      });
  });
});

スクリーンショット

ヘッダーのサービスロゴとサイドメニューの文字列の一部を変更して実行してみると、差分がない部分はそのまま、差分がある部分は浮き上がって出力されます。

diff

ハマった点

ImageMagick では無理

なぜ ImageMagick ではなく GraphicsMagick を使ったのかというと、差分画像を出力する compare が GraphicsMagick でしか使えず、 ImageMagick で同じことをやろうとすると面倒だったので、今回は GraphicsMagick を採用しました。

ヘッドレスサーバでは動かない

また、このタスクをサーバ上のCIで定期的に動かす場合、 Electron が動くように環境を構築しなければならないのですが、CentOS 6系以前では glibc が古くて動かなかったり、ヘッドレスサーバには別途仮想デスクトップ環境が必要になったりと、実行するための準備に少し手間取ってしまいました。

まとめ

  1. outputBaseImg() で比較元のスクリーンショットを取得
  2. outputChangeImg() で比較先のスクリーンショットを取得
  3. outputDiffImg() で差分画像を取得

というのが、大まかな流れになります。

この仕組みを導入してから、場合によっては数時間から数日かかっていた確認作業が、1時間弱で完了するようになり、目視では漏れてしまう差分も確認出来るようになりました!

しかし、大量のページに対して、Nightmare と GraphicsMagick を同時に実行すると、マシンパワーが足りなくなってしまうため、現状は並列ではなく直列で実行しています。

高スペックのマシンであればAVAの真骨頂である並列実行により、さらに高速化することが出来るかもしれません!

プロジェクトではさらに、差分出力のための閾値を変えたり、差分がある場合にだけ画像を出力させるようにし、差分が大きいものから優先的に確認するように運用しています。

今はまだモックでの確認のみでしか利用していませんが、今後はAPIやDB疎通ありのシナリオテストの中でこの仕組みを導入して、E2Eテストの自動化等にも取り組んでいければと思っています。

山口 祐司

(プロダクト開発2グループ)

2016年中途入社。サービスとバックエンドのあいだのフロントエンドエンジニア。

NEXT