GhostDriverでWebアプリケーションのテストを高速化する

f:id:hutyao:20130105043408p:plain

Seleniumを使ったテストは遅いという話をよく聞きます。理由として考えられるのは以下の3つです。

  • テスト対象のWebアプリケーションが遅いため
  • 遅いテストを書いているため
  • 実際にWebブラウザを動かしているため

1つ目と2つ目は、環境やコードの書き方を変えることで改善可能です。しかし、3つ目はどうでしょうか。実際のブラウザを高速化することなど、そのブラウザの開発者でもない限り不可能です。となると、できることは、可能な限り高速なブラウザを選択することです。

PhantomJSはヘッドレスブラウザです。ヘッドレスブラウザというのはGUIのないブラウザのことで、GUIの描画処理を行わない分、通常のブラウザに比べて高速に動作するという特徴があります。そのため、時間が掛かりがちなWebアプリケーションのGUIテストを高速化するためのソリューションとして注目されています。また、マルチプラットフォームであり、CUIのみのOSでもGUIテストを行えるという利点もあります。

GhostDriverは、そのPhantomJSをバックエンドとするWebDriver Wire Protocolの実装です。Selenium WebDriver APIを用いて、PhantomJSでのテストを行うことを目的として、Ivan De Marino氏によって作られました。

今回は、このGhostDriverの使い方について書きます。

準備

PhantomJSのインストール

PhantomJS 1.8以降には、GhostDriverが標準でバンドルされています。1.8以降のバージョンのPhantomJSをインストールすれば、GhostDriverを別途インストールする必要はありません。 PhantomJSのインストール方法は、OSごとに方法が異なります。詳細については以下のページを参照してください。

PhantomJS: Download and Install

Seleniumのダウンロード

Seleniumには、バージョン2.27.0からGhostDriverのサポートが追加されました。GhostDriverを利用するための実装が、各言語バインディングに追加されています。

今回はJavaでGhostDriverを利用する例を示します。JavaでGhostDriverを利用するためには、まず以下のページから最新バージョンのselenium-java-2.x.0.zipをダウンロードします。

Downloads - selenium - Browser automation framework - Google Project Hosting

ダウンロードしたものを解凍し、selenium-java-2.x.0.jarと、libsフォルダ以下のJARファイルを全てクラスパスに追加します。

SeleniumのJARファイルはMavenを利用してダウンロードすることもできますが、2012年1月7日現在、PhantomJSを動かすためのクラスを含むJARファイルがMavenのセントラルリポジトリ上で提供されていないため、そのJARファイルのみがダウンロードされません。どうしても利用したい場合、先のZIPファイルをダウンロードし、その中に含まれるphantomjsdriver-1.0.0.jarを、自身で依存関係に追加する必要があります。この問題についてはgithubのIssueに挙がっているので、いずれ解決されると思われます。

GhostDriver Selenium bindings should be available via Maven · Issue #142 · detro/ghostdriver

1/12 追記

GhostDriverがMavenリポジトリに登録されたようです。pom.xmlに以下の記述を追加することで利用可能になります。

<dependency>
  <groupId>com.github.detro.ghostdriver</groupId>
  <artifactId>phantomjsdriver</artifactId>
  <version>1.0.1</version>
</dependency>

使い方

SeleniumJavaバインディングでは、PhantomJSDriverというクラスを利用してPhantomJSを操作します。"/path/to/phantomjs"の部分は、PhantomJSのバイナリファイルへのパスを指定します。

DesiredCapabilities caps = new DesiredCapabilities();
caps.setCapability(
        PhantomJSDriverService.PHANTOMJS_EXECUTABLE_PATH_PROPERTY,
        "/path/to/phantomjs"
);
WebDriver driver = new PhantomJSDriver(caps);
driver.get("http://www.google.com");
WebElement searchBox = driver.findElement(By.name("q"));
searchBox.sendKeys("phantomjs");
searchBox.submit();
System.out.println(driver.getTitle());
driver.quit();

バイナリファイルのあるディレクトリに対してパスが通っていれば、バイナリファイルへのパスを指定する必要はありませんが、PhantomJSDriverには引数を受け取らないコンストラクタがないため、空のDesiredCapabilitiesを渡すことになります。

上記のコードを実行すると、コンソール上に「PhantomJS is launching GhostDriver... Ghost Driver running on port xxxxx」というメッセージが出力された後、「phantomjs - Google 検索」という文字が出力されます。画面上には一切表示されませんが、バックグラウンドでPhantomJSが起動し、HTTPリクエスト/レスポンスの送受信、HTMLの解析、JavaScriptの実行などが行われていることがわかります。

スクリーンキャプチャの取得

PhantomJSはGUIのないブラウザですが、Webページのキャプチャを取得することができます。GhostDriverを利用する場合も、以下のようにしてキャプチャの取得が可能です。

WebDriver driver = new PhantomJSDriver(caps);
driver.get("http://www.google.com");
File screenshot = ( (TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
Files.copy(screenshot, new File("/path/to/phantomjs-screencapture.png"));

Selenium Grid上での使用

Selenium Grid上でGhostDriverを使用する場合は、以下のようなコマンドラインオプションを付加してPhantomJSを起動し、PhantomJSをGridのnodeとしてhubに対して登録します。

$ java -jar /path/to/selenium-server-standalone-2.x.0.jar -role hub
$ phantomjs --webdriver=8080 --webdriver-selenium-grid-hub=http://127.0.0.1:4444/

nodeを登録したhubに対して、RemoteWebDriverでブラウザを指定して接続することで、Grid上でPhantomJSを使ったテストを動かすことができます。

WebDriver driver = new RemoteWebDriver(
        new URL("http://127.0.0.1:4444/wd/hub"),
        DesiredCapabilities.phantomjs());

実行速度の比較

GhostDriverを使ったテストにおいて、実ブラウザと比べて実際にどれほどの実行速度の差があるのか、簡易的に計測してみました。以下のグラフは、FirefoxDriver、ChromeDriver、PhantomJSDriverの三つのドライバを使って、ブラウザの起動、操作、終了までを20回ほど繰り返し、それぞれ掛かった時間の合計をまとめたものです。

f:id:hutyao:20130104223401p:plain

ブラウザの操作に関しては実ブラウザと比べるとなかなかの速さです。やはり画面の描画がないのが大きいようです。逆に、ブラウザの起動と終了に関しては実ブラウザと大差はありませんでした。この点は意外です。

まとめ

これまで、Seleniumを使ってヘッドレスブラウザでのテストを行う場合、選択肢はHtmlUnitのみでした。しかし、HtmlUnitはあくまで実ブラウザをシミュレートしたものであって、実ブラウザとの挙動の差があるのが難でした。しかしその点、PhantomJSはGoogle Chromeでも使われているレンダリングエンジンであるWebKitをベースとしており、ほとんど実ブラウザと同じといっても良いレベルです。

ただ、GhostDriverを一通り使ってみた感じでは、実ブラウザに比べてまだまだ不安定な部分があるように思いました。存在するはずの要素を見つけられない、リンクをクリックしてもうまく次のページへ遷移しないなど、同じコードでも、他のドライバの使用時には起こらないようなエラーが起こることが多々ありました。また、Wire Protocolに関しても未実装のコマンドがまだ多くあるようです

GhostDriverはまだまだ実用レベルではありませんが、高再現性かつ高速という点では大きなアドバンテージを持っています。特に、テストケースが増えるとSlow Tests問題を起こしやすいGUIレベルの自動テストでは、高速にテストを実行するための基盤は必須です。そして、それは実ブラウザを使っていては限界があります。そういった場面では、やはりヘッドレスブラウザが活躍してきます。この先、安定性が向上すれば、GhostDriverはWebアプリケーションを包括的にテストする際の有用な選択肢となってくるのではないでしょうか。

参考

SafariDriverの使い方

先日、Seleniumのバージョン2.21がリリースされ、SafariDriverという新たなドライバが加わりました。名前から分かる通り、Apple製のブラウザであるSafariを動かすためのドライバです。

これまで、IE、FirefoxGoogle ChromeOperaAndroidiPhone(iOS版 Safari)用のドライバはありましたが、デスクトップ版Safari用のドライバはありませんでした。Selenium RCを使えばSafariでもテストを動かすことは可能でしたが、JavaScriptサンドボックスによる制限を回避できなかったり、新しいWebDriver APIが使えないなどの欠点がありました。

今回新しく追加されたSafariDriverは、サーバ部分がSafariの機能拡張として実装されており、前述した問題を解決するものになっています。

準備

SafariDriverを使うためには、機能拡張のビルドからインストールまでを自分で行う必要があります(2012年4月17日現在)。今回はその方法について解説したいと思います。

なお、SafariDriverを使うためにはバージョン5.0以上のSafariが必要になりますので、注意してください。

Safari Developer Programへの登録

Safariに独自の機能拡張をインストールする場合、デベロッパ証明書が必要になります。証明書を取得するために、まずはSafari Developer Programの登録を行います。

Safari Developer Program – Apple Developer

上記のURLにアクセスし、「今すぐ参加する」から登録を開始します。

新規登録か、既にAppleデベロッパとして登録済みかどうかを聞かれますので、自身の状況に適切な選択肢を選んで下さい。

私の場合は新規登録で、かつApple IDを持っていたので、「Safariデベロッパプログラムに登録するにあたり現在持っているApple IDを使用します。」を選択しました。

ここから先の画面は選んだ選択肢によって変わってきますが、大きくは変わらないと思うので、私が選んだ選択肢を基準に説明していきます。

次は、個人で登録するか会社で登録するかを聞かれます。私は個人を選択しました。

Apple IDを入力します。

確認画面が表示されるので、問題が無ければ「Continue」をクリック。

使用許諾契約。

登録完了です。「Get Started」をクリックしてメンバーセンターへ。

証明書の取得

登録が完了したので、次は証明書を取得します。メンバーセンターの画面中央あたりにある「Developer Certificate Utility」のアイコンをクリックします。

「Create Certificates」をクリック。

「Add Certificate」をクリック。

証明書の取得に必要な、証明書署名要求(CSR)というものを作成する手順が表示されます。

証明書署名要求を作成する手順はOSによって異なるようです。ここではMacでの作成手順を紹介していますが、Windowsを使用している方は@os0xさんの記事を参照してみてください。

Safari拡張の作り方 – 0xFF

Macで証明書署名要求を作成するには、キーチェーンアクセス.appを使用します。キーチェーンアクセスのメニューから、「証明書アシスタント」 >「 認証局に証明書を要求…」 を選択します。

以下のような画面が表示されるので、「メールアドレス」、「通称」を入力し、「ディスクに保存」を選択後、「続ける」をクリックして下さい。証明書署名要求を保存する場所を聞かれるので、適当な場所を選択して保存します。

これで証明書署名要求が作成できました。

ブラウザに戻って、証明書署名要求の作成手順が表示されている画面から「Continue」をクリックすると、証明書署名要求をアップロードする画面が表示されるので、先ほど作成したファイルを選択し、「Generate」をクリックします。

証明書の発行中…

発行完了!「Continue」で次へ。

発行された証明書をダウンロードします。「Download」ボタンから証明書を適当な場所へ保存します。

ダウンロードした証明書のファイルをダブルクリックすれば、自動的に証明書がインストールされます。これで機能拡張をインストールする準備は完了です。

機能拡張のビルド

次はSafariDriverの機能拡張をビルドして、インストール用のファイルを作成します。ビルド自体は簡単で、必要なファイルをSeleniumリポジトリからチェックアウトし、ビルド用のコマンドを叩くだけです。

$ svn checkout http://selenium.googlecode.com/svn/tags/selenium-2.21/ selenium-2.21
$ cd selenium-2.21
$ ./go safari

ビルドが完了するとbuild/javascript/safari-driverの下にSafariDriver.safariextensionが作られているはずです。これがSafari機能拡張のファイルです。

機能拡張のインストール

作成した機能拡張を、Safariにインストールします。まず、Safariのメニューから環境設定を開き、「詳細」をクリックしてから、「メニューバーに“開発”メニューを表示」を選択します。これでメニューバーに「開発」という項目が増えるので、そこから「機能拡張ビルダーを表示」を選択します。

機能拡張ビルダーのウィンドウの左下に「+」というボタンがあるので、そこから「機能拡張を追加」を選択し、そこで先ほど作成したSafariDriver.safariextensionを選択します。画面に機能拡張が追加されるので、「インストール」をクリックすれば、機能拡張のインストールは完了です。

動かしてみる

ここまででSafariDriverを使うための準備は整いました。実際にSafariDriverでSafariを動かしてみましょう。以下のようなクラスを作成し、実行してみてください。

import static org.openqa.selenium.support.ui.ExpectedConditions.*;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.safari.SafariDriver;
import org.openqa.selenium.support.ui.WebDriverWait;

public class SafariDriverTest {

    private WebDriver driver;

    @Before
    public void createDriver() {
        driver = new SafariDriver();
    }

    @After
    public void quitDriver() {
        driver.quit();
    }

    @Test
    public void shouldBeAbleToPerformAGoogleSearch() {
        driver.get("http://www.google.co.jp/");
        driver.findElement(By.name("q")).sendKeys("webdriver");
        driver.findElement(By.name("btnG")).click();
        new WebDriverWait(driver, 5).until(titleIs("webdriver - Google 検索"));
    }

}

Safariが起動し、Googleのページが開いたでしょうか?

SafariDriverで出来ないこと

SafariDriverは開発途上なので、以下の機能はまだ実装されていないようです。

  • executeScript()メソッドによるJavaScriptの実行
  • フレームの切り替え
  • ドラッグ&ドロップなどを実現するための、インタラクションAPI

この他にも既知のバグなどがあるようなので、実際に使う際は注意してください。

参考

FirefoxDriver Tips

FirefoxDriverの使い方に関する少量のメモです。

Firefoxの設定を変更する

Firefoxでは、ブラウザの設定やアドオンのデータなどは全てプロファイルに紐付けられています。プロファイルのデータは特定のディレクトリに置かれており、Firefoxの起動時に読み込まれます。

ただし、FirefoxDriverを用いてFirefoxを起動した場合は既存のプロファイルは読み込まれません。常に新しいプロファイルが作成され、終了時に破棄されます。そのため、設定などはほとんど初期状態のままになります。起動時に独自の設定を反映させたい場合、Firefoxのプロファイルを扱うためのクラスが用意されているので、それを使います。

FirefoxProfile profile = new FirefoxProfile();
profile.setPreference("general.useragent.override", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)");
profile.setPreference("intl.accept_languages", "en-us, en");
WebDriver driver = new FirefoxDriver(profile);

FirefoxProfileというのが、プロファイルを扱うためのクラスです。
setPreference()メソッドは設定値を追加、変更します。一つ目の引数に設定名、二つ目の引数に設定値を指定します。設定値のタイプには、文字列、真偽値、整数値の三種類があるので、二つ目の引数にも、Stringintbooleanの三種類の型の値を渡すことができます。

上記の例では、general.useragent.overrideでユーザーエージェントを、intl.accept_languagesで言語設定を変更しています。

また、プロキシ設定の変更には専用のクラスが用意されているので、network.proxy.httpなどを直接弄るよりは、こちらを使ったほうが楽です。

Proxy proxy = new Proxy();
proxy.setHttpProxy("proxyhost:8080");
profile.setProxyPreferences(proxy);

アドオンをインストールする

FirefoxDriverを使ってFirefoxを起動する際に、任意のアドオンをインストールすることができます。インストールするには、あらかじめアドオンのファイルをダウンロードしておく必要があります。

FirefoxProfile profile = new FirefoxProfile();

// Firebugをインストール
File firebug = new File("firebug-1.8.4-fx.xpi");
profile.addExtension(firebug);
profile.setPreference("extensions.firebug.currentVersion", "1.8.4");

// FireMobileSimulatorをインストール
File firemobilesimulator = new File("firemobilesimulator-1.2.3-fx.xpi");
profile.addExtension(firemobilesimulator);
profile.setPreference("msim.current.carrier", "DC");
profile.setPreference("msim.current.id", "1");

WebDriver driver = new FirefoxDriver(profile);

ただし、この方法を用いると、アドオンのインストールや初期化処理によって、Firefoxの起動にかかる時間が増えてしまいます。一時的にアドオンを利用するだけならあまり問題はないのですが、恒久的にアドオンを使用するようなら、あらかじめアドオンをインストールしたプロファイルを用意しておき、それを次の方法で再利用するようにしたほうが効率的です。

既存のプロファイルを利用する

前に述べた通り、FirefoxDriverを用いてFirefoxを起動すると新たなプロファイルが作成されます。ただし、既存のプロファイルを指定して起動することも可能です。既存のプロファイルを用いるには、プロファイルのデータが含まれているディレクトリを以下のように指定します。

File profileDir = new File("myprofiledir");
FirefoxProfile profile = new FirefoxProfile(profileDir);
WebDriver driver = new FirefoxDriver(profile);

プロファイルディレクトリの場所でなく、プロファイル名を指定することもできます。
プロファイル名はプロファイルマネージャから確認できます。

ProfilesIni profilesIni = new ProfilesIni();
FirefoxProfile profile = profilesIni.getProfile("myprofile");
WebDriver driver = new FirefoxDriver(profile);

以下のような指定方法もあります。

System.setProperty("webdriver.firefox.profile", "myprofile");
WebDriver driver = new FirefoxDriver();

実行ファイルを指定する

Firefoxをデフォルトのインストール先と異なる位置にインストールしている場合、FirefoxDriverがFirefoxの実行ファイルを見つけられず、起動に失敗することがあります。こういった場合、自分自身で実行ファイルの位置を指定しなければなりません。

File binaryFile = new File("D:\\apps\\Mozilla Firefox\\firefox.exe");
FirefoxBinary binary = new FirefoxBinary(binaryFile);
WebDriver driver = new FirefoxDriver(binary, null);

FirefoxBinaryFirefoxの実行ファイルを扱うクラスです。実行ファイルを表すFileオブジェクトをコンストラクタの引数として受け取ります。

Macの場合の指定方法は以下のようになります。

File binaryFile = new File("/Applications/FirefoxBeta/Firefox.app/Contents/MacOS/firefox-bin");
FirefoxBinary binary = new FirefoxBinary(binaryFile);
WebDriver driver = new FirefoxDriver(binary, null);

以下の記述も同様です。

System.setProperty("webdriver.firefox.bin", "/Applications/FirefoxBeta/Firefox.app/Contents/MacOS/firefox-bin");
WebDriver driver = new FirefoxDriver();

一時ファイルを残す

あまり利用する場面はないと思いますが、WebDriverが作成する一時ファイルを削除しないようにすることができます。これにより、FirefoxDriverが作成した一時プロファイルを削除せずに残すことも可能です。

System.setProperty("webdriver.reap_profile", "false");
WebDriver driver = new FirefoxDriver();

ログをファイルに出力する

WebDriverが吐いたログをファイルに出力することができます。何かしら不審な挙動をしている際には、役に立つ情報になるかもしれません。

FirefoxProfile profile = new FirefoxProfile();
File logFile = new File("webdriver.log");
profile.setPreference("webdriver.log.file", logFile.getAbsolutePath());
WebDriver driver = new FirefoxDriver(profile);

新しい方法でのページの読み込み検出

Seleniumのバージョン2.9からの実験的な機能として、ページの読み込みが完全に完了するまで待機しない設定が可能になったらしいです。しかし、自分の環境ではうまく動きませんでした…。

FirefoxProfile profile = new FirefoxProfile();
profile.setPreference("webdriver.load.strategy", "fast");
driver = new FirefoxDriver(profile);

Selenium 2で非同期処理を待機する5つの方法

リッチなユーザインターフェースを備えたWebアプリケーションでは、Ajaxやアニメーションなどの非同期処理はよく用いられます。こういったWebアプリケーションをSeleniumでテストする際、従来の静的なユーザインターフェースを持つWebアプリケーションと同じようにテストを作成していると、実際にテストを動かした時に次のような問題が発生することがあります。

  • 存在するはずの要素が見つからない(あるいはその逆)
  • 画面全体、もしくは特定の要素の内容が更新されていない

例えば、以下のようなソースコードです。

ajaxButton.click();
WebElement fooElement = driver.findElement(By.id("foo"));

非同期処理を伴うボタンをクリックした後にfooというIDを持つ要素を探していますが、この要素が非同期処理の完了後に表示される要素であった場合、うまく動かないおそれがあります。なぜなら、要素を検索した時点でまだ要素が存在していない可能性があるためです。
こういった問題の原因は、ブラウザが非同期処理の完了を待たず、一続きに操作を実行してしまうことにあります。したがって、こういったWebアプリケーションを正しくテストするためには、何らかの方法で非同期処理の完了を待機する必要があります。
Selenium 2で非同期処理を待機する方法は5つあります。
今回は例として、上記のソースコードを、それぞれの方法に当てはめた形で書き換えてみます。

1. Thread.sleep() を使う

ajaxButton.click();
Thread.sleep(5000);
WebElement fooElement = driver.findElement(By.id("foo"));

最も単純なのはThread.sleep()を使う方法です。実行中のスレッドを一定時間停止することで、非同期処理の完了を待ちます。
しかしこの方法は、Thread.sleep()が終了した時点で非同期処理が完了していることを保証しません。そのため、非同期処理が予想外に長引いてしまった場合にうまくいかない可能性があります。また逆に、非同期処理が一瞬で終わってしまった場合には、無駄な待ち時間が発生してしまいます。
したがって、この方法は本格的な運用では用いるべきではありません。ごく簡単な動作確認や説明のために使用するだけに留めておきましょう。

2. 非同期処理が完了するまでループする

ajaxButton.click();
WebElement fooElement = null;
for (int i = 0;; i++) {
    try {
        fooElement = driver.findElement(By.id("foo"));
        break;
    } catch (NoSuchElementException e) {
        if (i > 30) {
            throw e;
        }
    }
    Thread.sleep(1000);
}

非同期処理の完了が確認できるまでループさせる方法です。この例における完了条件は、非同期処理の実行後に画面内に現れると予想される要素が見つかることです。
ループ内では、まずfindElement()を実行して要素を探します。そこで例外が発生しなかった場合、つまり要素が見つかった場合には、完了条件を満たしたのでループを抜けて待機処理を終えます。例外が発生した場合は、要素が見つからなかったということです。その時はループを抜けずに、1000ミリ秒のインターバル後に再度findElement()を実行します。これを、要素が見つかるまで繰り返します。タイムアウト処理として、iの値が一定値を超えた場合には例外を投げるようにしています。
Thread.sleep()の方法と異なり、この方法ならば待機後に非同期処理が完了していることが保証されます。非同期処理の状態を一定間隔で確認することで、無駄な待ち時間も極力抑えることができます。
基本的にこの方法であらゆる非同期処理のパターンに対処できますが、記述が冗長で、完了条件の判定処理と、インターバルやタイムアウトなどの処理が混在してしまうのが難点です。処理を別クラスに分けて再利用可能な形にしてもいいのですが、それよりも以下の方法を用いるほうが楽です。

3. Waitクラスを使う

Wait wait = new Wait() {
    @Override
    public boolean until() {
        try {
            driver.findElement(By.id("foo"));
            return true;
        } catch (NoSuchElementException ignore) {}
        return false;
    }
};
ajaxButton.click();
wait.wait("Cannot find element!", 30000);
WebElement fooElement = driver.findElement(By.id("foo"));

Waitクラスは、非同期処理の待機を簡単に実現するためのユーティリティクラスです。Seleniumには、こういったクラスがあらかじめ用意されています。
Waitクラスは抽象クラスとして定義されていて、until()をオーバーライドして使用します。オーバーライドしたuntil()の中に非同期処理の完了条件を記述します。メソッドの戻り値の型はbooleanで、非同期処理の完了が確認できた時にtrueを返し、それ以外の時にはfalseを返すように実装します。
このuntil()が実際に呼び出されるのはwait()の内部です。wait()の内部では、until()を実行して、その戻り値を評価し、trueが返却されるまでこれを繰り返すようになっています。なお、このwait()java.lang.Objectに定義されているwait()と名前が同じですが、シグネチャが異なるので注意してください。
このように、Waitクラスを用いれば、自身でループ処理などを記述することなく簡単に非同期処理の完了を待機できます。インターバルやタイムアウトなどの設定もwait()の引数で指定するだけなので、非常に簡潔です。
ただ、Waitクラスは便利なクラスではあるのですが、このクラスはSelenium RCで使用されていた少し古いクラスです。Selenium 2で待機処理を実現する場合、より使い勝手が良くなったWebDriverWaitクラスを使うのがおすすめです。

4. WebDriverWaitクラスを使う

Wait<WebDriver> wait = new WebDriverWait(driver, 30);
ExpectedCondition<WebElement> presenceOfElementIdentifiedAsFoo = new ExpectedCondition<WebElement>() {
    public WebElement apply(WebDriver driver) {
        return driver.findElement(By.id("foo"));
    }
};
ajaxButton.click();
WebElement fooElement = wait.until(presenceOfElementIdentifiedAsFoo);

WebDriverWaitクラスは、上記のWaitクラスと同じく非同期処理を待機するためのユーティリティクラスです。名前の通り、WebDriverクラスとの組み合わせで使用することが想定されています。
使い方としては、ExpectedConditionインターフェースのapply()を実装し、その中に非同期処理の完了条件を記述します。そして、その実装クラスのインスタンスWebDriverWairuntil()に渡します。until()の内部では、以下のいずれかの条件を満たすまで、渡されたインスタンスapply()が繰り返し実行されます。

  • apply()実行時に例外が発生せず、戻り値がnullでない
  • apply()実行時に例外が発生せず、戻り値がBoolean.TRUEと等しい(戻り値の型がBooleanの場合)
  • あらかじめ設定されたタイムアウト時間に達した

タイムアウトの場合は例外が投げられますが、それ以外の場合は正常に非同期処理が完了したとみなされ、apply()の戻り値がそのままuntil()の戻り値として返されます。apply()の戻り値の型はExpectedConditionの型引数として指定したもので、この例の場合はWebElementです。
WebDriverWaitクラスはWaitクラスと比べ、until()の戻り値の型をユーザが決めることができるという柔軟さがあります。また、非同期処理の完了条件の部分を別のクラスへと委譲しているため、それぞれのクラスの再利用性が向上しています。

9/14 追記

Seleniumのバージョン2.6.0から以下のようなクラスが導入されました。

import static org.openqa.selenium.support.ui.ExpectedConditions.*;
// 中略
Wait<WebDriver> wait = new WebDriverWait(driver, 30);
ajaxButton.click();
WebElement fooElement = wait.until(presenceOfElementLocated(By.id("foo"));

ExpectedConditionsクラスは、よく使われる完了条件をまとめたクラスです。条件に応じたExpectedConditionを返してくれるメソッドをまとめて定義していて、上記のようにuntil()の引数に直接メソッドを記述するだけで、簡単に非同期処理の完了を待機できます。また、ExpectedConditionを返すメソッドは全てstaticメソッドなので、このように静的インポートを活用することによって、自然言語的で読みやすいソースコードになります。
ExpectedConditionsクラスには、上記のpresenceOfElementLocated()というメソッド以外にも以下のようなメソッドが用意されています。

  • titleIs()
  • titleContains()
  • visibilityOfElementLocated()
  • visibilityOf()
  • presenceOfAllElementsLocatedBy()
  • textToBePresentInElement()
  • textToBePresentInElementValue()
  • frameToBeAvailableAndSwitchToIt()
  • invisibilityOfElementLocated()
  • elementToBeClickable()
  • stalenessOf()

それぞれのメソッドがどのような完了条件を表しているか、メソッド名から大体わかるとは思いますが、詳しく知りたい場合はExpectedConditionsJavadocを参照してください。

5. implicitlyWait() を設定する

driver.manage().timeouts().implicitlyWait(30, TimeUnit.SECONDS);
ajaxButton.click();
WebElement fooElement = driver.findElement(By.id("foo"));

これまでの方法は全て、待機処理をソースコード内に明示的に記述したものでした。それに対してimplicitlyWait()は、待機処理の暗黙的実行を示すものです。
implicitlyWait()を用いて待機時間を設定すると、要素の検索時にその要素が見つからなかった場合、そこですぐに例外を出さずに、設定された時間だけ要素の出現を待機するようになります。一度implicitlyWait()で待機時間を設定したDriverは、それ以降全ての要素検索で暗黙的に待機を行います。
非常に簡潔に待機処理を実現できるメソッドではありますが、その反面、要素の検索時にしか利用できないという短所もあります。例えば、要素が非表示になるまで待機したり、ページのタイトルが変更されるまで待機するといった場合は、このメソッドでは対応できません。他の方法を用いる必要があります。とはいっても、簡単なWebアプリケーションのテストであれば、この方法で全く問題ないでしょう。

まとめ

以上がSelenium 2で非同期処理を待機する5つの方法です。特別な理由がない限り、4と5の方法を用いるのがベストだと思います。個人的には、明示的に待機処理を記述する4の方法が好みです。
他にもこんな方法があるよ、といったものがあれば是非教えてくださると嬉しいです。

WebDriverを統合したSelenium 2を使ってみる

Seleniumとは

Seleniumとは、Webアプリケーションのテストを自動化するためのフレームワークです。Seleniumが提供するコマンドやAPIを用いることで、実際にWebブラウザを動かしながらWebアプリケーションの動作を検証することができます。これにより、従来手動で行っていたWebアプリケーションにおける回帰テストの多くを自動化することが可能になります。

Selenium 2(Selenium WebDriver)とは

Selenium 2は、先月の8日に正式版がリリースされた、Seleniumプロジェクトの新しいプロダクトです。Selenium 2の最大の特徴は、やはりWebDriverとの統合でしょう。これについては、WebDriverの開発者であるSimon Stewart氏の話も含めた詳しい内容が以下の記事に載っているので、そちらを参照してください。
Selenium 2 (別名 Selenium WebDriver) がリリース
この統合により、WebDriver APIを用いたテストが作成できるようになりました。今回は、このWebDriver APIを使ってテストを作成してみます。WebDriver APIは、JavaPythonRubyC#による実装がありますが、今回はJavaからWebDriver APIを使ってみます。

準備

Selenium 2を利用するためには、Selenium 2のライブラリを参照できるようにする必要があります。Mavenを利用している場合は、pom.xmldependenciesに以下を追加するだけです(x には最新バージョンの番号を入れてください)。

<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
    <version>2.x.0</version>
</dependency>

Mavenを利用しない場合は、以下のURLにアクセスして、selenium-java-2.x.0.zipをダウンロードしてきます。
Downloads - selenium - Browser automation framework - Google Project Hosting
ダウンロードしたものを解凍し、その中にあるselenium-java-2.x.0.jarと、libsフォルダ以下のjarファイルを全てクラスパスに追加します。

動かしてみる

まずは簡単なプログラムを動かしてみます。以下のようなクラスを作成します。

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.firefox.FirefoxDriver;

public class GoogleTest {

    public static void main(String[] args) {
        WebDriver driver = new FirefoxDriver();
        driver.get("http://www.google.co.jp/");
        driver.quit();
    }

}

プログラムを実行すると、Firefoxが起動してGoogleのトップページが表示されます。
FirefoxDriverというのが、Firefoxを動かすためのWebDriverの実装クラスです。FirefoxDriver以外にも、以下のようなDriverが提供されています。

ほとんどが直接ブラウザを動かすものですが、実際のWebブラウザを起動せずにWebブラウザの動きをシミュレートするHtmlUnitDriverというものもあります。Webブラウザを起動する必要がないため、他のDriverよりも実行速度が早いという特徴があります。内部ではRhinoを使っているようです。
ここでは、FirefoxDriverインスタンスを生成することでWebブラウザを起動し、そのインスタンスgetメソッドを呼び出すことで、Googleのトップページを開いています。getメソッドは、引数で指定したURLに対してGETリクエストを送ります。トップページを開いた後は、quitメソッドでWebブラウザを閉じています。このように、ごく簡単な記述で、ブラウザの起動から、操作、終了までを制御することができます。
次は、Googleのトップページを開いた後に、「selenium」というキーワードでの検索を行ってみます。

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.firefox.FirefoxDriver;

public class GoogleTest {

    private static WebDriver driver;

    public static void main(String[] args) {
        driver = new FirefoxDriver();
        driver.get("http://www.google.co.jp/");
        searchFor("selenium");
        driver.quit();
    }

    private static void searchFor(String keyword) {
        WebElement searchBox = driver.findElement(By.name("q"));
        searchBox.sendKeys(keyword);
        searchBox.sendKeys("\n");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) { /* ignore */ }
    }

}

新たにsearchForというメソッドを作成しました。このメソッドは、引数に指定されたキーワードを用いてGoogle検索を行うためのメソッドです。プログラムを実行すると、「selenium」というキーワードが検索ボックスに入力され、インスタント検索による検索結果が表示されます。
searchForメソッドの中では、検索ボックスの要素を探し、その要素に対して入力キー情報を送るといった処理をしています。Thread.sleep()を行っているのは、インスタント検索によるAjax通信および検索結果のレンダリングを待機するためです。もしも待機せずに、検索結果からさらに別の要素を探したりしようとすると、要素が見つからないといった例外が発生してしまう場合があります。

JUnitと組み合わせる

実際に自動テストを実現するためには、Webブラウザを動かすだけでなく、動作結果を検証する必要があります。検証には、JUnitTestNGなどのテスティングフレームワークを用いることができます。今回は、JUnitと組み合わせてテストを作成してみます。
先程のクラスを以下のように書き換えます。

import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;

import org.junit.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.firefox.FirefoxDriver;

public class GoogleTest {

    private static WebDriver driver;

    @Test
    public void searchForSeleniumWebsite() throws Exception {
        driver = new FirefoxDriver();
        driver.get("http://www.google.co.jp/");
        searchFor("selenium");
        assertThat(driver.getTitle(), is("selenium - Google 検索"));
        driver.quit();
    }

    private static void searchFor(String keyword) throws InterruptedException {
        WebElement searchBox = driver.findElement(By.name("q"));
        searchBox.sendKeys(keyword);
        searchBox.sendKeys("\n");
        Thread.sleep(3000);
    }

}

基本的な構成はそのままに、JUnit 4形式のテストクラスへと書き換えました。
@Testアノテーションが付いたsearchForSeleniumWebsiteメソッドが、検索後の画面遷移を検証するためのテストメソッドです。ここでは、検索後に正しく画面が遷移していることを、ページのタイトルを見て確認しています。assertThatメソッドを呼び出しているのがその部分です。
このテストの問題点として、テストメソッドが増えた際に、メソッド毎に毎回ブラウザを起動してしまうという点があります。動作としては問題ないのですが、テストメソッドが増えるごとに時間が余計にかかってしまいます。また、テストが途中で失敗した場合に、ブラウザが起動しっぱなしになってしまうという問題もあります。これらの問題を解決するためには、以下のような書き換えを行います。

import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;

import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.firefox.FirefoxDriver;

public class GoogleTest {

    private static WebDriver driver;

    @BeforeClass
    public static void setUpBeforeClass() {
        driver = new FirefoxDriver();
    }

    @AfterClass
    public static void tearDownAfterClass() {
        driver.quit();
    }

    @Test
    public void searchForSeleniumWebsite() throws Exception {
        driver.get("http://www.google.co.jp/");
        searchFor("selenium");
        assertThat(driver.getTitle(), is("selenium - Google 検索"));
    }

    private static void searchFor(String keyword) throws InterruptedException {
        WebElement searchBox = driver.findElement(By.name("q"));
        searchBox.sendKeys(keyword);
        searchBox.sendKeys("\n");
        Thread.sleep(3000);
    }

}

JUnit 4の@BeforeClass@AfterClassアノテーションを使って、テストの実行前と実行後にWebブラウザの起動と終了の処理を入れています。実行前にFirefoxDriverインスタンスを生成し、それを全てのテストメソッドで使い回せる形にしています。テスト終了後は、テストの成功・失敗に関わらず、Webブラウザは正しく終了されます。

おわりに

今回はSelenium 2の基本的な部分を使ってみましたが、他にも色々な機能があります。詳しくは参考資料を参照してください。
洗練されたWebDriver APIを用いることで、可読性の高いWebブラウザベースでの自動テストを簡単に作成できるようになりました。これからプロジェクトで自動テストを導入しようと考えてる方は、是非一度試してみてください。

Selenium - Web Browser Automation

余談

headlessなWebブラウザを用いたテストとの比較ですが、Seleniumを用いたテストでは実際のWebブラウザを動かすため、headlessブラウザより正確な動作結果を示すでしょう(仕様に忠実という意味ではなく)。また、Seleniumは数多くのプラットフォームをサポートしています。そのため、OSやWebブラウザごとの挙動の差異や、動作速度の検証にも用いることができます。
ただ、実際のWebブラウザを起動する分、テスト内容に直接関係が無い部分で時間がかかってしまうというデメリットもあります。このあたりは一長一短というところでしょうか。うまく使い分けていけたらいいですね。