生産性向上ブログ

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

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

概要

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

今回は、selenium-webdriverで実際のメンテナンスを意識したコードを書いてみます。

コードをメンテナンスしやすくするために重要なのは、なんらかの変更が発生したときに修正するポイントを最小にすることです。そのためには、各テストで重複しがちなコードを可能な限り意味があるコードとして切り出すことが重要です。今回は、hookとページオブジェクトを使って前回書いたテストコードをリファクタしていきます。

hook

今回の記事でのhookは、一般的なテストフレームワークには存在する beforeafter などのテスト共通の前処理や後処理を行うための仕組みを指します。もちろん、前回使ったmochaにもhookの仕組みは存在します

Seleniumテストで必要となるhook処理として、各テストごとに行うWebDriverインスタンスの生成と削除があります。同じインスタンスを全テストで使いまわすという方法もありますが、それを行うとセッションなどが引き継がれてしまってテスト間で依存関係が発生してしまうので、おすすめしないです。

mochaにはroot level hookの仕組みがあるので、それを使って全テスト共通のhookを記述します。まず、 test/init.js に以下の記述を行います。

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

test.beforeEach(function() {
    this.timeout(300000);
    global.driver = new webdriver.Builder().forBrowser('chrome').build();
});

test.afterEach(function() {
    global.driver.quit();
});

これは、各テスト開始時にWebDriverインスタンスをglobalの driver 変数に割り当て、各テスト終了時にインスタンスを削除します。

beforeEachtimeout メソッドを実行しているのは、hookにもデフォルトで2秒のタイムアウトが設定されているためです。このタイムアウトは、テストケースのタイムアウトとは別に存在します。

ついでに、テストケース側のタイムアウトを各テストごとに書くのはめんどうなので、 package.jsonmocha コマンドに --timeout パラメータを渡します。

  "scripts": {
    "test": "./node_modules/.bin/mocha --require intelli-espower-loader --timeout 300000"
  },

そして、前回の test/reserveapp.js を次のように書き換えます。

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

test.describe('Reserve App', function() {
    test.it('should work', function*() {
        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 === '予約完了');
    });
});

1テストだけだと数行減るだけなのであまり効果を実感しづらいかもしれませんが、今後テストケースが増えていくにつれて毎回インスタンスの初期化と削除を行わなくてよくなるのは楽です。

例えば、使用するブラウザの種類を変えたいとか、ウィンドウサイズを固定する処理を入れたい、というときに共通化を行っていないと全テストを修正する必要が発生するため、とてもメンテナンスが大変になります。さらに、そういった処理がテストケース側のコードに入ると見通しが悪くなります。

また、後処理をhookに切り出すことにより、テストが失敗したときも必ず実行されるようになるというメリットがあります。実はこれまではテストが途中で終了してしまうとWebDriverインスタンスが終了されずに残ってしまう、つまりブラウザのウィンドウが開きっぱなしになってしまうという問題がありました。今回の修正により、テストがエラーや失敗で終了するときでもブラウザが正しく終了されて余計なプロセスが残らないようになります。

以上のようなメリットがあるので、初期化と後処理はhookとして切り出しておくことをおすすめします。

ちなみに、この修正により特定のテストを指定して実行したいときはコマンドで渡す方法はできなくなります。例えば、次のような実行方法ではエラーになります。

$ npm test test/reserveapp.js 

> selenium-webdriver-samples@1.0.0 test /Users/miyajan/project/selenium-webdriver-samples
> mocha --require intelli-espower-loader --timeout 300000 "test/reserveapp.js"



  Reserve App
    1) should work


  0 passing (68ms)
  1 failing

  1) Reserve App should work:
     ReferenceError: driver is not defined

代わりに、テストケース側でonlyを呼びましょう。

test.describe('Reserve App', function() {
    test.it.only('should work', function*() {
        ...
    });
});

この状態で npm test を実行すると only() を付けたテストケースのみが実行されます。

ページオブジェクト

ページオブジェクトとは、UI層を共通化してテストケース側からHTML構造を抽象化するためのデザインパターンです。WebDriverのAPIをテストコードから直接呼び出すと、テストコードがテスト対象のHTMLに依存してしまうため、UI変更に非常に弱くなってしまいます。ページオブジェクトは、ページの構造を隠蔽して、そのページが提供するサービスをインターフェースとして提供することによって、テストコードのメンテナンス性を高めてくれます。

selenium-webdriverにはページオブジェクトの作成をサポートする仕組みはないので、独自に実装していく必要があります。まずは、 page/basepage.js に基底クラスを実装します。

const webdriver = require('selenium-webdriver');
const until = webdriver.until;

class BasePage {
    constructor(driver, locatedElementSelector, url = null, open = false) {
        this.driver = driver;
        this.waitTimeMS = 10000;

        if (open) {
            this.driver.get(url);
        }

        this.driver.wait(until.elementLocated(locatedElementSelector), this.waitTimeMS);
    }

    getTitle() {
        return this.driver.getTitle();
    }
}

module.exports = BasePage;

ES6のおかげで、JavaScriptのクラスもprototypeみたいな独特の記述なしで直観的に書けるようになったことを実感できます。

driver 以外のオプションはページオブジェクトであまり一般的ではないかもしれませんが、Seleniumテストでよく行う処理を簡単に書けるようにするためのものです。

locatedElementSelector パラメータは、ページのロード完了を待つためのものです。静的なWebページだと必要ないですが、動的なWebページやシングルページアプリなどではロード完了のタイミングがテストコード側からだと判断しにくいです。そのため、特定のセレクタの要素が存在を確認することによってロード完了と判断することが多いです。

open パラメータにtrueを渡すと、コンストラクタ実行時に url パラメータで指定したURLに遷移します。これは、テスト開始時などに直接そのページに遷移するためのもので、テスト途中に他のページの操作によって画面遷移するときは使いません。

この基底クラスを使って、前回の宿泊予約サービスのページをクラス化していきます。まずは、最初の予約フォームページを page/reserveapp/formpage.js に以下のように書きます。

const BasePage = require('./basepage.js');
const ConfirmPage = require('./confirmpage');
const webdriver = require('selenium-webdriver');
const By = webdriver.By;

class FormPage extends BasePage {
    constructor(driver, open = false) {
        const submitSelector = By.id('goto_next');
        const url = 'http://example.selenium.jp/reserveApp/';
        super(driver, submitSelector, url, open);

        this.submitSelector = submitSelector;
    }

    setDate(date) {
        this.driver.findElement(By.id('reserve_year')).clear();
        this.driver.findElement(By.id('reserve_year')).sendKeys(date.getFullYear());
        this.driver.findElement(By.id('reserve_month')).clear();
        this.driver.findElement(By.id('reserve_month')).sendKeys(date.getMonth() + 1);
        this.driver.findElement(By.id('reserve_day')).clear();
        this.driver.findElement(By.id('reserve_day')).sendKeys(date.getDate());
    }

    setName(name) {
        this.driver.findElement(By.id('guestname')).sendKeys(name);
    }

    submit() {
        this.driver.findElement(this.submitSelector).click();
        return new ConfirmPage(this.driver);
    }
}

module.exports = FormPage;

次に、予約内容の確認ページを page/reserveapp/confirmpage.js に実装します。

const BasePage = require('./basepage.js');
const FinalPage = require('./finalpage');
const webdriver = require('selenium-webdriver');
const By = webdriver.By;

class ConfirmPage extends BasePage {
    constructor(driver) {
        const commitSelector = By.id('commit');
        super(driver, commitSelector);

        this.commitSelector = commitSelector;
    }

    confirm() {
        this.driver.findElement(this.commitSelector).click();
        return new FinalPage(this.driver);
    }
}

module.exports = ConfirmPage;

こちらは直接開かれることはないので、コンストラクタに open パラメータは用意しません。

同様に、予約完了ページを page/reserveapp/finalpage.js に実装します。

const BasePage = require('./basepage.js');
const webdriver = require('selenium-webdriver');
const By = webdriver.By;

class FinalPage extends BasePage {
    constructor(driver) {
        super(driver, By.id('returnto_checkInfo'));
    }
}

module.exports = FinalPage;

そして、これらのページオブジェクトを使って test/reserveapp.js を書き換えます。

const assert = require('assert');
const webdriver = require('selenium-webdriver');
const test = require('selenium-webdriver/testing');
const FormPage = require('../page/reserveapp/formpage');

test.describe('Reserve App', function() {
    test.it('should work', function*() {
        const formPage = new FormPage(driver, true);

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

        formPage.setDate(tomorrow);
        formPage.setName('Alice');
        const confirmPage = formPage.submit();
        const finalPage = confirmPage.confirm();

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

ページオブジェクトのおかげでテストケースのコードからHTML要素やByによるセレクタなどが消えたことがわかると思います。これによってUI変更に強くなりますし、さらにテストコードの見通しもよくなりました。また、別のテストケースを書くときに、すでにページオブジェクトが存在していれば実装がかなり楽になります。

一般的に、ページオブジェクトは一番最初の実装コストはやや上がってしまうものの、メンテナンスコストの削減、類似テストの追加コストの削減、コード行数の削減といったメリットがあります。

まとめ

今回は、hookとページオブジェクトを使って前回のサンプルコードを実践的なメンテナンスしやすいコードにリファクタしました。メンテナンスしやすくなるだけでなく、全体のコードの見通しもよくなるというメリットもあります。

今回の修正もGitHubのサンプルリポジトリに取り込んであるので、ご自由にお使いください。

次回: JavaScript in Selenium Test (3): selenium-webdriverをCircleCIで動かす