先日「東京 Node 学園 27 時限目」に参加して Puppeteer というライブラリを知りました。
Puppeteer は Node.js から Headless Chrome を操作するためのライブラリで、Google Chrome の開発チームが出している公式のものだそうです。

Node.js から Headless Chrome を扱うには「chrome-remote-interface」というものがありました。
私も「Headless Chrome を使ったフロントエンドのテスト」という記事の中で chrome-remote-interface の使い方を紹介しています。

その記事のなかで、ページをスクロールしたり、マウスでクリックするような操作をライブラリとして用意すれば Headless Chrome を使うのは十分に現実的だという感想を書きました。
逆にいうと、そのようなライブラリを用意しないと Headless Chrome をテストで使うのは厳しいということになります。

この Puppeteer は、そういった機能を提供してくれるライブラリです。
これは試してみなくてはいけないということで、chrome-remote-interface を使って書いたテストと同じ内容のものを Puppeteer を使って書いてみることにしました。

環境構築

今回、用意したのは次のような環境です。
Node.js のバージョンが現時点での最新のものになっている以外は、chrome-remote-interface を動かしたのと同じです。

Name Version
OS Ubuntu 16.04(Xenial)64bit
Node.js 8.4.0
Puppeteer 0.10.1

いつもどおり Vagrantfile や Ansible の playbook、テストのソースコードは GitHub で公開しています。
同じような環境を構築してテストを動かしてみたいというときは、puppeteer-test を clone して試してみてください。

Puppeteer の導入

Puppeteer は npm からインストールすることが可能です。
またインストール時には、Puppeteer の package.json と同じディレクトリに .local-chromium というディレクトリを作り、最新の Chromium がダウンロードされます。
そのため、npm だけでテスト環境を構築したいという希望も実現…………、できません。

Puppeteer がダウンロードしてくれるのは Chromium だけなので、Chromium を実行するのに必要なライブラリは別途インストールする必要があります。

ライブラリが足りない状態でテストを動かすと、次のようにエラーが表示されます。
エラーが無くなるまで表示されているライブラリのインストールを繰り返すことで、テストが実行できるようになります。
ただ、必要なライブラリも多いですし、こういった作業をするのが面倒だという場合は chromium のパッケージをインストールしてしまえば一発です。

Error: Failed to launch chrome!
/vagrant/node_modules/puppeteer/.local-chromium/linux-497674/chrome-linux/chrome: error while loading shared libraries: libpangocairo-1.0.so.0: cannot open shared object file: No such file or directory


TROUBLESHOOTING: https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md

ちなみに、私が試した限り Ubuntu 16.04(Xenial)で Puppeteer を使ったテストを動かすには次の 5 つのパッケージと、これらが依存しているパッケージが必要でした。

  • libgtk-3-0
  • libnss3
  • libxss1
  • libgconf-2-4
  • libasound2

troubleshooting.md にも必要なライブラリの記載がありますので、うまく動かないという場合には参考にしてみてください。

Nightmare では xvfb を使用するため、デバッグオプションを付けて起動する必要がありましたが、Puppeteer では普通に出力されるので楽ですね。

テスト

Puppeteer を使って Protractor のサイトにあるテストのサンプルを書いてたのが次のコードです。
chrome-remote-interface を使ったときのコードだけではなく、Nightmare を使ったコードと比べてもシンプルで分かりやすいのではないでしょうか。

'use strict';


let browser = null;
const puppeteer = require('puppeteer');
const expect = require('expect.js');


before(async () => {
    browser = await puppeteer.launch();
});

after(() => {
    browser.close();
});


describe('Headless chrome test', function () {
    describe('Protoractor test sample', function () {
        it('Should complete test', async function () {
            const page = await browser.newPage(),
                text = 'write first protractor test';
            await page.goto('https://angularjs.org');
            await page.focus('input[ng-model="todoList.todoText"]');
            await page.type(text);

            const button_add = await page.$('input[value="add"]');
            await button_add.click();

            const list = await page.$$('li[ng-repeat="todo in todoList.todos"]');
            expect(list).to.have.length(3);
            expect(await list[2].evaluate((element) => {
                return Promise.resolve(element.querySelector('span').textContent);
            })).to.be(text);

            await list[2].evaluate((element) => {
                element.querySelector('input').click();
            });
            expect(await page.$$('li[ng-repeat="todo in todoList.todos"] .done-true')).to.have.length(2);
        });
    });
});

Puppeteer を使う際の注意点

Puppeteer を使ったテストを書くにあたって参考にしたのは README.md と、そこからリンクされている API Documentation の 2 つです。
これらの情報があれば、特にハマるようなところは無いと思います。

また、今回は確認していないのですが、問題がある場合は GitHub の issueexample ディレクトリ のファイルが参考になると思います。

他にも、これは確認しておくといいかなと思った情報を、いくつか挙げておきますので参考にしてください。

ElementHandle

page.$() メソッドを使って要素を取得すると ElementHandle が返ります。
この ElementHandle ですが、page のように「$」や「$$」といったメソッドを持っていません。
そのため、次のようにクエリーセレクターである要素を取得したあとで、さらにクエリーセレクターを使い要素を絞り込むような書き方ができません。

const list = await page.$$('li'),
    button = await list[2].$('button');
button.click();

上のコードの listbutton の両方を取得したいときには、セレクタをこのように指定して取得することになります。

const list = await page.$$('li'),
    button = await page.$('li:nth-child(3) button');
button.click();

また、取得した要素をマウスでクリックしたいといったケースでは ElementHandle.evaluate() を使うという方法もあると思います。

const list = await page.$$('li'),
    list[2].evaluate((element) => {
        element.querySelector('button').click();
    });

テキストの取得

フロントエンドのテストを書くときに、タグに含まれるテキストを取得したいという状況は多いです。
しかし、残念ながら Puppeteer には pageElementHandle などからテキストを取得するメソッドがありません。

テキストを取得したい場合は ElementHandle.evaluate() を使って、次のように書くことになります。
HTML を取得したい場合も同様に innerHTML を返すようにします。

const title = await page.$('title'),
    text = title.evaluate((element) => {
        return element.textContent;
    });

.length / .click()

これは Puppeteer を使う際の注意点というよりは Promise の話になります。
クエリーセレクターでボタンを取得してクリックしたいときに、こんな感じで書けるときれいです。

await page.$('button').click();

もちろん、このコードは思うように動作しません。
これだと Pending 状態の Promise の length を取得しようとしたり、click メソッドを呼び出してしまうことになるためです。
少し冗長に見えますが、次のようにして、先に Promise の解決をしてやる必要があります。

const button = await page.$('button');
button.click();

まとめ

さて、今回は Puppeteer を使ってフロントエンドのテストを書いてみました。
Puppeteer は公開されてから、まだそれほど経っていないライブラリとのことですが、実際にコードを書いてみたところ完成度も高く感じました。

この記事を書く 2か月ほど前には「Nightmare を使ったフロントエンドのテスト」という記事を書いています。
その記事の中で Nightmare が良さそうに思うと書いたばかりではありますが、今回 Puppeteer を試してみて、Puppeteer に乗り換えることを決めました。

機能を比べると Nightmare と Puppeteer のどちらも、テストを書くには十分な機能を持っているように見えます。
上の記事では Nightmare のメリットとして Headless Chrome を自分で立ち上げる必要が無く、ヘッドレスではない Chrome が起動中でも起動できる点を挙げました。
しかしこれは、Puppeteer を使うことでも実現することができます。

Puppeteer を使う場合システムにインストールされている Chrome ではなく、インストール時にダウンロードした Chromium が使われます。
また puppeteer.launch() で Chromium が立ち上がり browser.close() で終了します。
そのため、自分で Chromium を起動する必要はないし、システムにインストールされている Chrome が起動していても問題ありません。

機能で選ぶのであれば、どちらを選んでもそれほど変わらないのではないでしょうか。
それでも私が Puppeteer に乗り換えようとしている理由は、Puppeteer を使ったテストのコードが自分の好みに近いということが大きいです。

Nightmare ではメソッドチェインをつなげて than で受けるような形になるため、どうしてもネストが深くなってしまいますし、少し読みにくく感じてしまいます。
その点、Puppeteer は Promise を返すように設計されているため、async/await と組み合わせることでシンプルに書くことができます。

プログラムのコードは書くことよりも読まれることの方が圧倒的に多いため、コードをきれいに、読みやすく書けるというのは、大きなメリットではないでしょうか。

コメントの投稿