生産性向上ブログ

継続的な生産性向上を目指すエンジニアのためのブログ

JavaScript in Selenium Test (1): selenium-webdriver & Mocha & power-assert

概要

JavaScriptを使ったSeleniumテストに興味が出てきたので、調査したことを何回かに分けて記事にまとめていきます。

今回は、selenium-webdriverMochapower-assertを使ってSeleniumテストに入門してみます。

環境

この記事は、以下の環境で実行しています。

  • OS: Mac 10.12.1
  • node.js: v6.9.1
  • Chrome: 54.0.2840.98 (64-bit)
  • chromedriver: 2.25

selenium-webdriver

簡単に、selenium-webdriverの特徴をまとめます。

  • Seleniumコミュニティ公式のJSクライアントライブラリ
    • GitHub
    • 以前はWebDriverJSという名前だったけど、npmパッケージ名を見るとselenium-webdriverがおそらく正式名称
  • WebDriverの主要なAPIをそのままの名前で提供している
    • 要はJavaとかの公式クライアントライブラリと同じ
  • npm上ではselenium-webdriverというパッケージ名
  • すべてのWebDriver APIは非同期に実行されるが、内部的にControlFlowというクラスでPromiseの実行順序を管理している
    • コールバック地獄に陥らずに、コード上では同期処理であるかのように記述できる

クイックスタート

ブラウザは、Chromeを使用します。そのために、まずはchromedriverをダウンロードしておきます。

wget https://chromedriver.storage.googleapis.com/2.25/chromedriver_mac64.zip
unzip chromedriver_mac64.zip

テスト対象のサイトには、日本Seleniumユーザーコミュニティが用意しているテスト用サイトを使います。実態は静的サイトでありながら、動的Webサービスであるかのように振る舞ってくれるので、今回のような実験目的ではとても便利なサイトです。

まずは、 selenium-webdriver パッケージを npm コマンドでインストールします。

npm install selenium-webdriver

quickstart.js という名前で以下のようなコードを書いてみます。

const webdriver = require('selenium-webdriver');
const By = webdriver.By;
const driver = new webdriver.Builder().forBrowser('chrome').build();

const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);

driver.get('http://example.selenium.jp/reserveApp/');
driver.findElement(By.id('reserve_year')).clear();
driver.findElement(By.id('reserve_year')).sendKeys(tomorrow.getFullYear());
driver.findElement(By.id('reserve_month')).clear();
driver.findElement(By.id('reserve_month')).sendKeys(tomorrow.getMonth() + 1);
driver.findElement(By.id('reserve_day')).clear();
driver.findElement(By.id('reserve_day')).sendKeys(tomorrow.getDate());
driver.findElement(By.id('guestname')).sendKeys('Alice');
driver.findElement(By.id('goto_next')).click();
driver.findElement(By.id('commit')).click();
driver.quit();

そして、 node コマンドで実行してみます。

node quickstart.js

コマンドを実行すると、chromeが立ち上がり、予約サイトを一通り操作して、chromeが閉じられます。一瞬で操作が終わるけど、ブラウザが立ち上がって閉じられるところまで進めば正しく動いたと考えてもらって問題ないです。

とりあえず、ここまではJavaとかでSeleniumテストを書いたことがある自分としては違和感のない内容です。selenium-webdriverならJavaScript独特の非同期処理を意識せずともコードが書けそうです。

まだテストコードでもなんでもなく、selenium-webdriverでブラウザを動かしてみただけなので、次はMochaを使って実際のテストを意識したコードを書いてみます。

selenium-webdriver + Mocha

まずは、 npm コマンドで mocha をインストールします。

npm install mocha

次に、 test ディレクトリを作成して、 reserveapp.js をその下に作成して、以下のようなコードを書いてみます。内容的には、クイックスタートの操作の後にタイトルをassertするだけのテストです。

const assert = require('assert');
const webdriver = require('selenium-webdriver');
const By = webdriver.By;

describe('Reserve App', () => {
    it('should work', () => {
        const driver = new webdriver.Builder().forBrowser('chrome').build();

        const tomorrow = new Date();
        tomorrow.setDate(tomorrow.getDate() + 1);

        driver.get('http://example.selenium.jp/reserveApp/');
        driver.findElement(By.id('reserve_year')).clear();
        driver.findElement(By.id('reserve_year')).sendKeys(tomorrow.getFullYear());
        driver.findElement(By.id('reserve_month')).clear();
        driver.findElement(By.id('reserve_month')).sendKeys(tomorrow.getMonth() + 1);
        driver.findElement(By.id('reserve_day')).clear();
        driver.findElement(By.id('reserve_day')).sendKeys(tomorrow.getDate());
        driver.findElement(By.id('guestname')).sendKeys('Alice');
        driver.findElement(By.id('goto_next')).click();
        driver.findElement(By.id('commit')).click();

        assert(driver.getTitle() === '予約完了');

        driver.quit();
    });
});

これを mocha コマンドで実行してみると、エラーが発生しました。

$ ./node_modules/.bin/mocha test/reserveapp.js 


  Reserve App
    1) should work


  0 passing (190ms)
  1 failing

  1) Reserve App should work:
     AssertionError: ManagedPromise {
  flow_: 
   ControlFlow {
     propagateUnhandledRejections_: true,
     activeQueue_: 
      TaskQueue {
     == '予約完了'
      at Context.it (test/reserveapp.js:23:16)

どうやら、 driver.getTitle() はPromiseを返している様子。WebDriver APIはすべて非同期で実行されるので、よく考えれば当たり前のエラーです。

というわけで、Promiseを意識したコードに直してみます。mochaでPromiseを使うテストをするときは、最後にreturnする必要があります。

const assert = require('assert');
const webdriver = require('selenium-webdriver');
const By = webdriver.By;

describe('Reserve App', () => {
    it('should work', () => {
        const driver = new webdriver.Builder().forBrowser('chrome').build();

        const tomorrow = new Date();
        tomorrow.setDate(tomorrow.getDate() + 1);

        driver.get('http://example.selenium.jp/reserveApp/');
        driver.findElement(By.id('reserve_year')).clear();
        driver.findElement(By.id('reserve_year')).sendKeys(tomorrow.getFullYear());
        driver.findElement(By.id('reserve_month')).clear();
        driver.findElement(By.id('reserve_month')).sendKeys(tomorrow.getMonth() + 1);
        driver.findElement(By.id('reserve_day')).clear();
        driver.findElement(By.id('reserve_day')).sendKeys(tomorrow.getDate());
        driver.findElement(By.id('guestname')).sendKeys('Alice');
        driver.findElement(By.id('goto_next')).click();
        driver.findElement(By.id('commit')).click();

        return driver.getTitle().then(title => {
            assert(title === '予約完了');
            driver.quit();
        });
    });
});

しかし、またエラー。

$ ./node_modules/.bin/mocha test/reserveapp.js 


  Reserve App
    1) should work


  0 passing (2s)
  1 failing

  1) Reserve App should work:
     Error: timeout of 2000ms exceeded. Ensure the done() callback is being called in this test.

mochaのタイムアウトはデフォルトで2秒なのが原因のエラーです。

今度は、 this.timeout() でタイムアウトを5分くらいに変えます。

const assert = require('assert');
const webdriver = require('selenium-webdriver');
const By = webdriver.By;

describe('Reserve App', () => {
    this.timeout(300000);

    it('should work', () => {
        const driver = new webdriver.Builder().forBrowser('chrome').build();

        const tomorrow = new Date();
        tomorrow.setDate(tomorrow.getDate() + 1);

        driver.get('http://example.selenium.jp/reserveApp/');
        driver.findElement(By.id('reserve_year')).clear();
        driver.findElement(By.id('reserve_year')).sendKeys(tomorrow.getFullYear());
        driver.findElement(By.id('reserve_month')).clear();
        driver.findElement(By.id('reserve_month')).sendKeys(tomorrow.getMonth() + 1);
        driver.findElement(By.id('reserve_day')).clear();
        driver.findElement(By.id('reserve_day')).sendKeys(tomorrow.getDate());
        driver.findElement(By.id('guestname')).sendKeys('Alice');
        driver.findElement(By.id('goto_next')).click();
        driver.findElement(By.id('commit')).click();

        return driver.getTitle().then(title => {
            assert(title === '予約完了');
            driver.quit();
        });
    });
});

しかし、またまたエラー。

$ ./node_modules/.bin/mocha test/reserveapp.js 
/Users/miyajan/project/selenium-webdriver-samples/test/reserveapp.js:6
    this.timeout(300000);
         ^

TypeError: this.timeout is not a function
    ...

どういうことかよくわからないので調べたら、Arrow Functionを使うとthisにmochaのcontextがbindingされないとのことでした。mochaではArrow Functionを使うとハマりどころになってしまいそうなので、書き直します。

const assert = require('assert');
const webdriver = require('selenium-webdriver');
const By = webdriver.By;

describe('Reserve App', function() {
    this.timeout(300000);

    it('should work', function() {
        const driver = new webdriver.Builder().forBrowser('chrome').build();

        const tomorrow = new Date();
        tomorrow.setDate(tomorrow.getDate() + 1);

        driver.get('http://example.selenium.jp/reserveApp/');
        driver.findElement(By.id('reserve_year')).clear();
        driver.findElement(By.id('reserve_year')).sendKeys(tomorrow.getFullYear());
        driver.findElement(By.id('reserve_month')).clear();
        driver.findElement(By.id('reserve_month')).sendKeys(tomorrow.getMonth() + 1);
        driver.findElement(By.id('reserve_day')).clear();
        driver.findElement(By.id('reserve_day')).sendKeys(tomorrow.getDate());
        driver.findElement(By.id('guestname')).sendKeys('Alice');
        driver.findElement(By.id('goto_next')).click();
        driver.findElement(By.id('commit')).click();

        return driver.getTitle().then(function(title) {
            assert(title === '予約完了');
            driver.quit();
        });
    });
});

すると、ようやくテストが通りました。

$ ./node_modules/.bin/mocha test/reserveapp.js 


  Reserve App
    ✓ should work (6059ms)


  1 passing (6s)

しかし、WebDriver APIから値を取得するとthenが必要になってしまうのは辛いです。コールバック地獄に落ちる恐れがあります。

なので調べてみたところ、selenium-webdriverにはmochaをラップするtestingモジュールが存在しました。testingモジュールはジェネレータに対応しているので、 yield を使って以下のように書けるようになります。

const assert = require('assert');
const webdriver = require('selenium-webdriver');
const By = webdriver.By;
const test = require('selenium-webdriver/testing');

test.describe('Reserve App', function() {
    this.timeout(300000);

    test.it('should work', function*() {
        const driver = new webdriver.Builder().forBrowser('chrome').build();

        const tomorrow = new Date();
        tomorrow.setDate(tomorrow.getDate() + 1);

        driver.get('http://example.selenium.jp/reserveApp/');
        driver.findElement(By.id('reserve_year')).clear();
        driver.findElement(By.id('reserve_year')).sendKeys(tomorrow.getFullYear());
        driver.findElement(By.id('reserve_month')).clear();
        driver.findElement(By.id('reserve_month')).sendKeys(tomorrow.getMonth() + 1);
        driver.findElement(By.id('reserve_day')).clear();
        driver.findElement(By.id('reserve_day')).sendKeys(tomorrow.getDate());
        driver.findElement(By.id('guestname')).sendKeys('Alice');
        driver.findElement(By.id('goto_next')).click();
        driver.findElement(By.id('commit')).click();

        const title = yield driver.getTitle();
        assert(title === '予約完了');
        driver.quit();
    });
});

若干ジェネレータの知識はいるものの、どうにかコールバック地獄に陥ることなくSeleniumテストを書けそうという実感を得られました。

power-assert

ここで、power-assertを導入してみます。上記ぐらいのテストであれば通常のassertでも十分ですが、JSで本格的なテストを運用していく上ではpower-assertの詳細な出力があると非常に助かるためです。

speakerdeck.com

あと、↑のスライドを見て、 require('assert') のままでpower-assertが使えるようになったという話が気になっていたので、この機会に試してみます。

まずは、 power-assertintelli-espower-loader パッケージをインストールします。

npm install power-assert intelli-espower-loader

テストを失敗させないとpower-assertになっていることがわからないので、先ほどのテストのassertを以下のように書き換えます。

assert(title === 'hogehoge');

これで、以下のコマンドでテストを実行します。

./node_modules/.bin/mocha --require intelli-espower-loader test/reserveapp.js

すると、見事にpower-assertの詳細なメッセージが出力されることを確認できました。

  Reserve App
    1) should work


  0 passing (6s)
  1 failing

  1) Reserve App should work:

      AssertionError:   # test/reserveapp.js:27
  
  assert(title === 'hogehoge')
         |     |              
         |     false          
         "予約完了"           
  
  --- [string] 'hogehoge'
  +++ [string] title
  @@ -1,8 +1,4 @@
  -hogehoge
  +予約完了
  
  
      + expected - actual

      -false
      +true

なんと、本当に require('assert') を書き換えないままでpower-assertを使えるようになっていました。power-assertを導入するときも、power-assertを外したくなったときもコード側を一切書き換えることなく切り替えられるようになっています。これはすごいですね!

まとめ

JavaScriptを使ったSeleniumテストについて、selenium-webdriver + mocha + power-assertの組み合わせで書いてみました。

JavaScriptならではのハマりどころもいくつかあるものの、全体的には懸念されたコールバック地獄に落ちることなく思ったよりスマートなコードで書けそうという実感を得られました。

しかし、まだこの記事のコードはサンプルレベルで、実際の運用を考えたテストコードにはなっていません。次回以降、ページオブジェクトなどより実際のメンテナンス性などを考慮に入れたテストコードの書き方について探求していきます。

最後に、今回のコードは、GitHubリポジトリに公開しています。

https://github.com/miyajan/selenium-webdriver-samples

  1. リポジトリをチェックアウト
  2. chromedriverをダウンロード&解凍
  3. npm install
  4. npm test

の手順で今回実装したテストを動かせるようになっているので、実際に動かしてみたい人はご自由に使ってみてください。

次回: JavaScript in Selenium Test (2): selenium-webdriverでhookとページオブジェクト