ページオブジェクトパターンとマッパーを使ったメンテナンスしやすいSeleniumテストケース

Selenium Webdriverを使ったテストで、前回のスローテストと別に起きる大きな問題が、テストコードのメンテナンス性の悪さでした。

SeleniumのJUnit4テストコードを作るために、今までやっていたことは以下のような方法です。

  1. Firefoxの「Selenium IDEプラグインを使ってケースを自動生成
  2. テストデータ(入力値)のパターンを増やしてテストするため、上記のテストコードをコピペして、別のテストケースを作成

この方法だと、アプリケーションの修正などでテストコードの修正が必要な時に、悲惨なことになります*1

さておき、今回のネタは http://code.google.com/p/selenium/wiki/PageObjects にもあるように、ページオブジェクトパターンを使って書くといいよという話。

以下のコードを見直してみたいと思います。Googleのページにアクセスして検索ワード「Cheese!」で検索を行うテストです*2

    @Test
    void "Cheese! で検索(慣習的?)"() {
        def driver = webDriverRule.getDriver() // webDrvierRuleは前回のエントリで作ったJUnit4用ルール
        driver.get("http://www.google.co.jp/")

        def element = driver.findElement(By.name("q"))
        element.sendKeys("Cheese!")
        element.submit()

        WebDriverWait wait = new WebDriverWait(driver, 10)
        wait.until(presenceOfElementLocated(By.id("resultStats")))

        assert driver.getTitle() == "cheese! - Google 検索"
    }

ページオブジェクトパターンで書いてみる

これをページオブジェクトパターンを使うと、以下のようになりました。

    @Test
    void "Cheese! で検索(ページオブジェクトパターン)"() {
        def driver = webDriverRule.getDriver()
        def searchPage = new SearchPage(driver)

        def searchResultPage = searchPage.open().type("Cheese!").submit();

        assert searchResultPage.title == "cheese! - Google 検索"
    }

実際に書いてみて以下の効果があると思いました。

  • テストコードがシンプルになって可読性が上がる。
  • WebElementの操作(「By.name("q")」や「sendKeys()」)がページクラス側に閉じるので、メンテナンス性が高くなる。
    • アプリケーションの画面を修正した時に、テストメソッドを1個1個直していくのではなく、ページクラスの該当箇所を修正するだけになる。
    • テストコード側でリファクタリングが使える。
  • Theoriesランナーを使ったデータ駆動テストとも、相性がよい。

ページクラスは以下のとおり(こちらはJava)。

public class SearchPage extends AbstractPage {

    private WebDriver driver;

    @FindBy(how = How.NAME, name = "q")
    @CacheLookup
    private WebElement q;

    public SearchPage(WebDriver driver) {
        this.driver = driver;
        PageFactory.initElements(driver, this);
    }

    public SearchPage open() {
        driver.get("http://www.google.co.jp/");
        return this;
    }

    public SearchPage type(String searchWord) {
        q.sendKeys(searchWord);
        return this;
    }

    public SearchResultPage submit() {
        q.submit();

        WebDriverWait wait = new WebDriverWait(driver, 10);
        wait.until(presenceOfElementLocated(By.id("resultStats")));

        return new SearchResultPage(driver);
    }
}

はじめて知ったのですが、@CacheLookupというのがあるんですね。

このアノテーションを付けることで、同じページに対して何度もテストを実行する場合の実行時間の短縮が期待できます。といいつつ、どれくらい効果があるかはわからないのですが…。

マッパーを使ってページクラスの定義を楽にする。

ところで、ページオブジェクトパターンを採用すると、ページクラスを新たに作らなければならないので、ページが増えるたびにテストコードを作る初期コストが高くついてしまいます。ページクラスの定義は、単純(機械的に作れそう)なので、簡単に生成できるツールとかがあればよいのですが…。

ページクラスを作ってみて、WebElementに対してsendKeysをして入力を行う以下のようなメソッドの存在が気になります。フィールドの数に比例して定義していくのが、面倒ですね。

    // たとえば検索
    public SearchPage type(String searchWord) {
        q.sendKeys(searchWord); // q = WebElement
        return this;
    }

    // たとえばログイン
    public HomePage type(String login, String password) {
        login.sendKeys(searchWord);
        password.sendKeys(password);
        return this;
    }

この部分ですが、POJOとページクラスをマッピングするようなユーティリティが使えれば、色んなページクラスで汎用的な一つのメソッドに集約することができそうです。

まずは今回の検索ページのための以下のようなFixtureクラスを定義。AbstractFixtureは空の抽象クラスなので、ほぼPOJOです。

public class SearchFixture extends AbstractFixture {
    private String q;

   // setter, getter は省略
}

このFixtureオブジェクトを渡して以下のように使えれば、どれだけフィールドが増えてもページクラスは肥大化しないですね。要するに、BeanUtils.copyPropertiesのSelenium版です。

    public SearchPage type(Object fixture) {
        Util.sendProperties(fixture, this);
        return this;
    }

というわけで、Util.sendProperties()は、簡単に以下のような感じで作ってみました*3。リフレクションを使ってFixtureと同名のWebElementフィールドに対してsendKeysを呼び出します。

今はStringとテキストフィールドのWebElementのマッピングしかできないですが、アノテーションをうまく使えばbooleanとチェックボックスなど、色々と凝ったことができるように拡張できそうです。

    public static void sendProperties(AbstractFixture fixture, AbstractPage page)
            throws IllegalAccessException, NoSuchFieldException {

        Class<?> fixtureClass = fixture.getClass();
        Class<?> pageClass = page.getClass();

        for (Field pageField : pageClass.getDeclaredFields()) {
            // ページクラス側の WebElement を取り出す
            if (!pageField.getType().equals(WebElement.class)) {
                continue;
            }
            pageField.setAccessible(true);
            WebElement element = (WebElement) pageField.get(page);

            // 同じフィールド名をもつ FixtureClass 側の Object を取り出す
            Field fixtureField = null;
            try {
                fixtureField = fixtureClass.getDeclaredField(pageField
                        .getName());
            } catch (NoSuchFieldException e) {
                continue;
            }
            fixtureField.setAccessible(true);
            Object value = fixtureField.get(fixture);

            if (value == null) {
                continue;
            } else if (!(value instanceof String)) {
                continue;
            } else {
                // String なら WebElement.sendKeys()
                element.sendKeys((String) value);
            }
        }
    }

リポジトリ

完全なコードは https://github.com/tq-jappy/selenium-util にあります。

*1:経験談(泣)。とはいえ、一度動かして後は放置(メンテナンスしない)と割りきるのも場合によってはアリ、なのかも…?

*2:主旨と直接関係はないですが、テストコードはGroovyで書いています。テストメソッド名がJavaのメソッド名の制限に縛られずに自由に書けるし、assertが楽なので、それだけでも十分便利に感じてます。

*3:GitHub上のコードは修正を加えているので、微妙に違っています