Cypress + reg-cli でビジュアルリグレッションテスト環境を作る

経緯

BackstopJS が上手く行かなかったので Cypress を使う方法に切り替えたところ、わりと上手く行きそうだったのでこちらの方法に切り替えてみました。

成果物

スクリプトのベースは

こちらを参考に、自分で使いやすいようにさらにいくつかチョイ足しをした感じになります。

構成

ディレクトリ構造

PROJECT_ROOT/
    ├ cypress/
    │   ├ fixtures/
    │   │   └ visual-regression-test/
    │   │      └ config.json.sample                 // 設定サンプル
    │   │
    │   ├ integration/
    │   │   └ visual-regression-test/
    │   │      ├ cafe_bij_nacht-before.spec.js      // ref のキャプチャを取得するスクリプト
    │   │      └ cafeterras_bij_nacht-after.spec.js // test のキャプチャを取得するスクリプト
    │   │
    │   ├ plugins/
    │   │   └ index.js                              // イベントにフックさせて動作させるスクリプト
    │   │
    │   └ support/                                  // 略
    │
    ├ package.json
    ├ sterrennacht.js                               // Cypress + reg-cli のチェック後に reg-cli のレポートのHTMLを開く
    └ zonnebloemen.js                               // Cypress 実行時の設定パラメータを調整する

package.json

{
    "name": "cypres_bij_sterrennacht",
    "version": "0.0.1",
    "description": "Cypress + reg-cli によるビジュアルリグレッションテストのスクリプト。",
    "main": "sterrennacht.js",
    "scripts": {
        "result": "node ./sterrennacht",
        "cypress:before": "cypress run --config-file false --config trashAssetsBeforeRuns=true -s cypress/integration/visual-regression-test/cafe_bij_nacht-before.spec.js",
        "cypress:after": "cypress run --config-file false --config trashAssetsBeforeRuns=false -s cypress/integration/visual-regression-test/cafeterras_bij_nacht-after.spec.js",
        "cypress": "run-s cypress:*",
        "reg": "reg-cli ./cypress/screenshots/visual-regression-test/before ./cypress/screenshots/visual-regression-test/after ./cypress/screenshots/visual-regression-test/diff -R ./cypress/screenshots/visual-regression-test/report/index.html -M 0.2 -I",
        "test": "run-s cypress:* reg result"
    },
    "author": "Arm Band",
    "license": "ISC",
    "dependencies": {
        "browser-sync": "^2.26.14",
        "cypress": "^7.6.0",
        "npm-run-all": "^4.1.5",
        "opener": "^1.5.2",
        "reg-cli": "^0.17.0"
    }
}

npm scripts にタスクを記述、 yarn test で以下の4つのタスクを一括で動かします。

  1. Cypress による変更サイトのキャプチャの撮影
  2. Cypress による変更サイトのキャプチャの撮影
  3. reg-cli で1., 2.の比較とレポート生成
  4. レポートのHTMLを openner で開く

zonnebloemen.js

/**
 * zonnebloemen : 設定を生成する
 */
class zonnebloemen
{
    /**
     * getParameters         : 設定用のフラグを生成
     *
     * @param {booelan} flag : フラグ
     *
     * @return {Object}      : 設定パラメータの内容
     */
    getParameters (flag)
    {
        return flag === 'BEFORE' ? {
            outStr: 'before',
            urlKey: 'ref',
            flag  : true,
        } : {
            outStr: 'after',
            urlKey: 'test',
            flag  : false,
        };
    };
    /**
     * getConfig             : 設定を生成
     *
     * @param {Object} data  : JSONファイル のデータ
     * @param {booelan} flag : フラグ
     *
     * @return {Object}      : 設定の内容
     */
    getConfig (data, flag)
    {
        if (data !== undefined && data !== null && Object.keys(data).length > 0) {
            flag ? data.commons['trashAssetsBeforeRuns'] = true : data.commons['trashAssetsBeforeRuns'] = false;
        }
        return data;
    };
};

module.exports = zonnebloemen;

フラグによって変更前なのか変更後なのか、スクリプトを実行する際の設定の配列のパラメータを弄るクラス。

具体的には以下の2つ。

  • 変更前
    • 参照先URL: refキー にセットされた値
    • trashAssetsBeforeRuns(キャプチャ取得スクリプト実行前にキャプチャ格納ディレクトリをクリーンアップするか): true
  • 変更後
    • 参照先URL: testキー にセットされた値
    • trashAssetsBeforeRuns: false

config.json.sample

{
    "commons": {
        "url": {
            "ref": "https://www.example.jp",
            "test": "https://demo.example.jp"
        },
        "hideElements": [
            "#__bs_notify__",
            ".returnPageTop",
            ".c-returnPageTop",
            "#navbar",
            ".navbar"
        ],
        "screenshot": {
            "capture": "fullPage",
            "waitMsec": 1000
        }
    },
    "viewports": [
        {
            "name": "phone",
            "width": 375,
            "height": 667
        },
        {
            "name": "tablet",
            "width": 1024,
            "height": 768
        },
        {
            "name": "pc",
            "width": 1920,
            "height": 1080
        }
    ],
    "pages": [
        {
            "test_id": "top",
            "uri": "/"
        },
        {
            "test_id": "about",
            "uri": "/about.html"
        }
    ]
}

サンプルですがやりたいことは最低限押さえてあるかと。

  • commons:
    • url:
      • ref: お手本として参照するサイトのURL
      • test: テストしたいサイトのURL
  • viewports: デバイス(ブラウザ)の幅と高さ。デフォルトではスマートフォン、タブレット、PCとして3つのサイズを指定
  • pages: テストしたいページのURLとキャプチャのファイル名に使用するID

基本的にこの設定に従って Cypress でキャプチャを撮っていきます。

cafe_bij_nacht-before.spec.js

const flag              = 'BEFORE';

const configDataOrigin  = require('../../fixtures/visual-regression-test/config.json');
const zonnebloemenClass = require('../../../zonnebloemen');
const zonnebloemen      = new zonnebloemenClass();
const parameters        = zonnebloemen.getParameters(flag);
const configData        = zonnebloemen.getConfig(configDataOrigin, parameters.flag);

describe(`${parameters.outStr} screenshot`, function () {
    const pages     = configData.pages;
    const domain    = configData.commons.url[parameters.urlKey];
    const viewports = configData.viewports;
    const ss_dir    = parameters.outStr;
    var test_id     = '';
    var ss_path     = '';
    var url         = '';

    pages.forEach(({ test_id, uri }) => {
        viewports.forEach(({ name, width, height }) => {
            context(test_id, () => {
                beforeEach(() => {
                    url = domain + uri;
                    cy.visit(url);
                });

                it('take screenshot', function () {
                    ss_path = ss_dir + '/' + test_id + '-' + name;
                    cy.wait(configData.commons.screenshot.waitMsec);
                    cy.viewport(width, height);
                    cy.scrollTo('bottom');
                    cy.screenshot(ss_path, {
                        onBeforeScreenshot($el) {
                            for (const selector of configData.commons.hideElements) {
                                const $selector = $el.find(selector);
                                if ($selector) {
                                    $selector.hide();
                                }
                            }
                        },
                        capture: Cypress.env(configData.commons.screenshot.capture),
                    });
                });
            });
        });
    });
});

最初の定数 flag 以外は cafe_bij_nacht-before.spec.jscafeterras_bij_nacht-after.spec.js で共通となります。本当は1つのファイルにしたかったのですが上手く動かなかったので2つにしました。

やっていることは flag によって zonnebloemen.js で設定パラメータの配列を生成、その値を以てキャプチャを撮影する、というもの。

cafeterras_bij_nacht-after.spec.js

const flag              = 'AFTER';

const configDataOrigin  = require('../../fixtures/visual-regression-test/config.json');
const zonnebloemenClass = require('../../../zonnebloemen');
const zonnebloemen      = new zonnebloemenClass();
const parameters        = zonnebloemen.getParameters(flag);
const configData        = zonnebloemen.getConfig(configDataOrigin, parameters.flag);

describe(`${parameters.outStr} screenshot`, function () {
    const pages     = configData.pages;
    const domain    = configData.commons.url[parameters.urlKey];
    const viewports = configData.viewports;
    const ss_dir    = parameters.outStr;
    var test_id     = '';
    var ss_path     = '';
    var url         = '';

    pages.forEach(({ test_id, uri }) => {
        viewports.forEach(({ name, width, height }) => {
            context(test_id, () => {
                beforeEach(() => {
                    url = domain + uri;
                    cy.visit(url);
                });

                it('take screenshot', function () {
                    ss_path = ss_dir + '/' + test_id + '-' + name;
                    cy.wait(configData.commons.screenshot.waitMsec);
                    cy.viewport(width, height);
                    cy.scrollTo('bottom');
                    cy.screenshot(ss_path, {
                        onBeforeScreenshot($el) {
                            for (const selector of configData.commons.hideElements) {
                                const $selector = $el.find(selector);
                                if ($selector) {
                                    $selector.hide();
                                }
                            }
                        },
                        capture: Cypress.env(configData.commons.screenshot.capture),
                    });
                });
            });
        });
    });
});

上述で説明した通りなので省略。

plugins/index.js

/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************

// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)

/**
 * @type {Cypress.PluginConfig}
 */
// eslint-disable-next-line no-unused-vars
const path = require('path');
const fs = require('fs');

const isExistFile = (file) => {
    try {
        fs.statSync(file);
        return true;
    }
    catch (err) {
        if (err.code === 'ENOENT') {
            return false;
        }
    }
};

module.exports = (on, config) => {
    on('after:screenshot', (details) => {
        const str = details.path;
        const newPath = str.replace(/[\w\d_]+\-(before|after)\.spec\.js/, '');
        const newDir  = path.dirname(newPath);
        fs.mkdirSync(newDir, { recursive: true });
        fs.renameSync(details.path, newPath);
        return { path: newPath };
    });
    on('after:spec', (details) => {
        const newPath = details.absolute.replace('integration', 'screenshots');
        newPath.match(/[\w\d_]+\-(before|after)\.spec\.js/);
        const childNewPath = path.join(newPath, RegExp.$1);
        if (isExistFile(childNewPath)) {
            fs.rmdirSync(childNewPath);
        }
        if (isExistFile(newPath)) {
            fs.rmdirSync(newPath);
        }
    });
};

通常だとキャプチャの保存先のディレクトリ名がキャプチャ撮影スクリプトのファイル名そのものになってしまい、ディレクトリ名にドットが入るなどややもやっとする形式なので、そこを before or after のみになるように保存先ディレクトリのリネーム (ファイルコピー+オリジナルディレクトリ削除) を実行するようにしています。

sterrennacht.js

const opener = require('opener');
const dir    = {
    baseDir: './cypress/screenshots/visual-regression-test/',
    startPath: 'report/index.html',
};

opener(`${dir.baseDir}${dir.startPath}`);

opener で開くだけの簡単なスクリプト。

実行結果(サンプル)

試しに少し動かしてみます。サンプルはホーム | Kiribi Ususamaで、公開されているものを ref 、ローカルで Browsersync で開いたものを test として動作させてみます。

reg-cli レポートのトップ
reg-cli レポートのトップ

reg-cli のレポートのトップ。キャプチャのうちテストを通らなかったものが上部に、通ったものは下部に分類されて並べられています。

テストOKの例
テストOKの例

テストが通った例。トップページです。

テストNGの例
テストNGの例

テストNGの例。なんだかディレクトリ構造のツリー構造がずれているようです。左右にバーをスライドさせて比較を行うことができる他、差分を赤色で表示させるモードや、 ref のみの表示、 test のみの表示、と切り替えることもできます。ここも使いやすそうだとおもった点です。

これで、自分なりにビジュアルリグレッションテストができる環境が整備できた気がします。

余談

もはや恒例となりましたが、名前について。今回は「Cypres bij Sterrennacht」。オランダ語です。日本語訳すると「糸杉と星の見える道」。ゴッホの最晩年の作品ですね。名前は Cypress をツールとして使用しているから、というシンプルな理由です。

ちなみに自前の補助スクリプトの名前も

  • cafe_bij_nacht-before.spec.js: Cafe bij nacht →「夜のカフェ」
  • cafeterras_bij_nacht-after.spec.js: Cafeterras bij nacht →「夜のカフェテラス」
  • sterrennacht.js: Sterrennacht →「星月夜」
  • zonnebloemen.js: Zonnebloemen →「ひまわり」

でいずれもゴッホの作品の名前から採りました。

参考

Cypress

きっかけ

ベース

Docs

前回実施時のスクリーンショットの破棄 (trashAssetsBeforeRuns)

スクロール

要素を隠す

.hide()

find の戻り値

pluginsスクリプト のイベント

config

未使用

reg-cli

この記事を書いた人

アルム=バンド

フロントエンド・バックエンド・サーバエンジニア。LAMPやNodeからWP、Gulpを使ってejs,Scss,JSのコーディングまで一通り。たまにRasPiで遊んだり、趣味で開発したり。