フロントエンドのテストではよく Selenium が使われます。
スマートフォンのアプリケーション向けには Appium がありますし Eq で Angular のテストに使用している Protractor も Selenium を使用します。

しかし、この Selenium の環境を構築するのは、個人的には少し面倒に感じます。
たとえば Eq では Node.js で書かれた API のテストに mochaexpect.js を利用していますが、これらの環境は npm install を実行するだけで構築することができます。
それに対して Selenium では WebDriver の他にも jre や xvfb などのパッケージをインストールする必要があります。

しかし、これらはあくまで Selenium を利用するのに必要なものであって、Selenium から利用している Headless Chrome を動かすのには必要のないものです。
それなら Selenium を使わず Headless Chrome を直接叩くようにすれば、もっと簡単にテスト環境が構築できるのではと考えました。

そこで、今回 Protractor のサイトにあるテストのサンプルと同じ内容のものを Headless Chrome を利用して書いてみたいと思います。


環境構築

まずは Headless Chrome の利用できる環境を構築します。
私の使っている PC には Chromium と Node.js がインストールされているので、これをそのまま使うことも可能です。
ただ、今回は Jenkins などから動かすことも想定し vagrant を利用して Ubuntu 16.04 上に余計なものが入っていない環境を構築しました。
ここに chromium-browsernodejs をインストールするのですが Ubuntu 16.04 に含まれる Node.js は 4.2.6 と少々古いため nave.sh を利用して、最新の Node.js をインストールして使いました。

まとめると次のようになります。
本来、もっと古いバージョンの Nose.js でも動作するはずなのですが、私の書いたコードで async/await を使っているため Node.js 7.6.0 以上が推奨です。

Name Version
OS Ubuntu Linux 16.04(Xenial)64bit
Chromium 59.0.3071.109-0ubuntu0.16.04.1289
Node.js 8.1.3

また、環境構築に利用した Vagrantfile と Ansible の playbook、テストのソースコードは GitHub で公開しています。
headless-chrome-test を clone して vagrant up を実行するだけで、簡単に同じ環境を構築して、試していただけると思います。


Headless Chrome の起動

次のコマンドで Headless Chrome が立ち上がります。
ウィンドウサイズには 1024×768 を指定しますが、サイトなどに合わせて変更してください。
なお、テストを実行するには、あらかじめ Chromium が起動している必要があります。
nohup コマンドなどを使い立ち上げっぱなしにしておいたり、テストスクリプトで起動させるなどする必要があります。

chromium-browser --headless --disable-gpu --remote-debugging-port=9222 --window-size="1024x768"

chrome-remote-interface

Node.js から Headless chrome を使うためのモジュールが chrome-remote-interface です。
使い方については README.md と GitHub の WikiChrome DevTools Protocol Viewer が参考になりました。
また GitHub の issue には「question」というラベルの付いたものがあります。
Google で検索する前に、似たような質問が無いか確認してみることお勧めします。


マウスでのクリックと文字の入力

chrome-remote-interface は Chrome Debugging Protocol のインタフェースでしかなく、テストに便利な機能が組み込まれているわけではありません。
そのため、クエリセレクターで要素を指定してクリックイベントを発生させるといったことはできません。
クリックイベントを発生させるには、座標を指定して「mousePressed」イベントと「mouseReleased」イベントを発生さることになります。
これについては Wiki の「Trigger synthetic click events」に記載があります。
また、同様に文字を入力するには 1 文字ずつ「keyDown」イベントと「keyUp」イベントを発生さて入力します。

この方法以外にも Runtime.evaluate メソッドを使うなどの方法はあるのですが、やはりそのままでは使いにくいため、メソッドを作って簡単に呼び出せるようにしておくと便利です。
以下はテストコードで利用しているマウスでのクリックと、文字の入力をするためのメソッドです。


マウスでのクリック

nodeId = (nodeId instanceof Object) ? nodeId : {nodeId: nodeId};
    const boxmodel = await client.DOM.getBoxModel(nodeId),
        content = boxmodel.model.content,
        parameters = Object.assign({
            x: content[0] + Math.floor((content[2] - content[0]) / 2),
            y: content[1] + Math.floor((content[5] - content[1]) / 2),
            button: 'left',
            clickCount: 1
        }, options);

    parameters.type = 'mousePressed';
    await client.Input.dispatchMouseEvent(parameters);
    parameters.type = 'mouseReleased';

    return client.Input.dispatchMouseEvent(parameters);

文字の入力

const sendKeys = async (client, nodeId, text, options) => {
    nodeId = (nodeId instanceof Object) ? nodeId : {nodeId: nodeId};
    const parameters = Object.assign({}, options);
    await client.DOM.focus(nodeId);

    return text.split('').reduce(async function (collection, value) {
        return collection.then(function () {
            parameters.type = 'keyDown';
            parameters.text = value;

            return client.Input.dispatchKeyEvent(parameters);
        }).then(function () {
            parameters.type = 'keyUp';

            return client.Input.dispatchKeyEvent(parameters);
        });
    }, Promise.resolve());
};

cheerio

chrome-remote-interface を使ってテストを書くなら cheerio などの HTML をパースするためのライブラリがあると便利です。
というのも、テストでは HTML のどこかの要素のテキストがどうなっているのかを調べることがよくあると思うのですが、chrome-remote-interface ではこのとき Runtime.evaluate を使うことになります。

次の例はテストの中で cheerio を使っている部分と、それを Runtime.evaluate を使って書き直した場合の比較です。
好みの問題ではありますが cheerio を使った場合と比べて、少し、わかりにくい印象があります。


cheerio を使った場合

const content = await client.DOM.getOuterHTML({nodeId: items[2]}),
    element = cheerio.load(content.outerHTML);
expect(element('span').text()).to.be(text);

Runtime.evaluate を使った場合

const selector = 'li[ng-repeat="todo in todoList.todos"]:nth-child(3) span',
    expression = `document.querySelector(\'${selector}\').innerText`,
    innerText = await client.Runtime.evaluate({expression: expression});
expect(innerText.result.value).to.be(text);

テスト

次のコードが、今回、書いたテストのコードです。

今回のコードではマウスのクリックや、文字の入力を行う関数も書かれているため長くなってしまっていますが、テストのコードは 94 行目の describe 以降の部分です。

そこを切り出してみると、十分、可読性のあるコードが書けているのでは無いでしょうか。

chrome-remote-interface を利用したテストは Page.navigate でテストをするページを開き Page.loadEventFired のタイミングでページの内容を取得して、内容を確認するといった流れになると思います。

複数のページを遷移してテストをする場合は Question: Can I redefine the Page.loadEventFired event after it’s been fired? の例のように loadEventFired イベントの中でさらに navigate を読び出します。
このとき Page.loadEventFired ではなく client.once を使うのがポイントです。
なぜなら Page.loadEventFired() を複数回呼び出した場合、既存のメソッドが上書きされるのではなく、2 つ目のメソッドが追加されてしまうからです。

'use strict';


const fs = require('fs');
const cheerio = require('cheerio');
const CDP = require('chrome-remote-interface');
const expect = require('expect.js');


/**
 * Get node ID by selector
 *
 * @param {Object} client
 * @param {string} selector
 * @param {null|number|undefined} nodeId
 */
const querySelector = async (client, selector, nodeId) => {
    const result = await client.DOM.querySelector({
        nodeId: nodeId || 1,
        selector: selector
    });

    return result.nodeId;
};

/**
 * Get all node ID by selector
 *
 * @param {Object} client
 * @param {string} selector
 * @param {null|number|undefined} nodeId
 */
const querySelectorAll = async (client, selector, nodeId) => {
    const result = await client.DOM.querySelectorAll({
        nodeId: nodeId || 1,
        selector: selector
    });

    return result.nodeIds;
};

/**
 * Send key event
 *
 * @param {Object} client
 * @param {Object|number} nodeId
 * @param {string} text
 * @param {Object|undefined} options
 */
const sendKeys = async (client, nodeId, text, options) => {
    nodeId = (nodeId instanceof Object) ? nodeId : {nodeId: nodeId};
    const parameters = Object.assign({}, options);
    await client.DOM.focus(nodeId);

    return text.split('').reduce(async function (collection, value) {
        return collection.then(function () {
            parameters.type = 'keyDown';
            parameters.text = value;

            return client.Input.dispatchKeyEvent(parameters);
        }).then(function () {
            parameters.type = 'keyUp';

            return client.Input.dispatchKeyEvent(parameters);
        });
    }, Promise.resolve());
};

/**
 * Send click event
 *
 * @param {Object} client
 * @param {Object|number} nodeId
 * @param {Object} options
 */
const click = async (client, nodeId, options) => {
    nodeId = (nodeId instanceof Object) ? nodeId : {nodeId: nodeId};
    const boxmodel = await client.DOM.getBoxModel(nodeId),
        content = boxmodel.model.content,
        parameters = Object.assign({
            x: content[0] + Math.floor((content[2] - content[0]) / 2),
            y: content[1] + Math.floor((content[5] - content[1]) / 2),
            button: 'left',
            clickCount: 1
        }, options);

    parameters.type = 'mousePressed';
    await client.Input.dispatchMouseEvent(parameters);
    parameters.type = 'mouseReleased';

    return client.Input.dispatchMouseEvent(parameters);
};

describe('Headless chrome test', function () {
    describe('Protoractor test sample', function () {
        it('Should complete test', function (done) {
            (async () => {
                const client = await CDP(),
                    text = 'write first protractor test';
                await Promise.all([
                    client.Page.enable(),
                    client.DOM.enable(),
                ]);

                client.once('Page.loadEventFired', (async () => {
                    const document = await client.DOM.getDocument(),
                        todo = await querySelector(client, 'div[app-run="todo.html"]', document.root.nodeId),
                        field = await querySelector(client, 'input[type="text"]', todo),
                        button = await querySelector(client, 'input[value="add"]', todo);
                    await sendKeys(client, field, text);
                    await click(client, button);

                    const items = await querySelectorAll(client, 'li[ng-repeat="todo in todoList.todos"]', todo);
                    expect(items).to.have.length(3);

                    const content = await client.DOM.getOuterHTML({nodeId: items[2]}),
                        element = cheerio.load(content.outerHTML);
                    expect(element('span').text()).to.be(text);

                    const item = await querySelector(client, 'input', items[2]);
                    await click(client, item);

                    const completed = await querySelectorAll(client, '.done-true');
                    expect(completed).to.have.length(2);

                    client.close();

                    done();
                }));

                client.Page.navigate({url: 'https://angularjs.org/'});
            })();
        });
    });
});

まとめ

今回メソッドに切り出した部分をモジュールとして用意するなどすれば Headless Chrome でテストを書くという選択肢は十分、現実的だと思いました。
フロントエンドの開発をしている場合、すでに Node.js や Chrome がインストールされていると思いますので、ローカルにテスト環境を構築するのも簡単にできそうです。

ただ、今回のように Ansible などのプロビジョニングツールを使うのであれば、1 度 playbook などを用意した後はそれを実行するだけです。
この場合、Selenium の環境を構築するのも手間は変わらないようにも思います。

そして、Selenium は chrome-remote-interface では対応していない多くのブラウザに対応しています。
それらのブラウザでのテストが必要であれば Selenium を選ぶしかないでしょう。

また、実際にテストを書いてみて、すでに Selenium を使ったテストがある場合に乗り換えるべきメリットは感じられなかったというのが正直なところです。

しかし、Selenium よりも導入がしやすかったのは確かですし、コードの可読性も悪くはありません。
新規にテストを書く場合で、基本的に Chrome のみの対応という部分が問題にならなければ、現実的な選択肢の 1 つとなるのではないでしょうか。

コメントの投稿