Selenium2.x で Ajax なWebアプリケーションをテストしよう 〜 Facebook の自動あいさつ返答機能を実装 〜

この記事では、Facebook のあいさつ(Poke)機能への返信を題材に、沢山たまるとウザい嬉しいあいさつを自動で返すスクリプトを書くことで、Selenium2.x の使い方、特に Ajax アプリをテストする方法について学べるようにする。
Ajax がからんだWebアプリケーションのエンドツーエンドの最近のテスト手法についてのまとめにもなっていると思う。

最初の3節ぐらいは「Seleniumとは〜」とか「テストってのはさー」とかゴタクをごたごた書いているので、Seleniumの実際のコード見た方がはえぇよ。って言う人はコードが出てくるまで記事を飛ばすと良い。

こんな記事を気合入れて書いて公開した当日に…FacebookのUIが変わって…作ってたスクリプト動かなくなってしまった…orz。俺が何かやったり買うと事件が起きるんや・・「なにか買うとその直後に安くなったり、新機種がでたりするという都市伝説」は本当やった…http://theinterviews.jp/yamashiro/12194

明日か週末、スクリプトと記事の内容修正します…orz

Selenium とは

最初に Selenium とはなんなのか簡単に触れる。

Selenium とはWebアプリケーションのテストツールである。
ボタンをクリックする、リンクをクリックする、チェックボックスを選択する…などを表すスクリプトJavaRubyC#Pythonでかけます)を記述すると、それをSeleniumが解釈し、ブラウザを実際に立ち上げ、スクリプトに従ってSeleniumがブラウザを操作し、Webアプリケーションが正しく動作してるかをテストすることができる。

以下のような特徴がある。

  • サポートしてるOSが豊富
  • サポートしてるブラウザが豊富
  • サポートしてる言語が豊富
  • Selenium IDE や Grid などといった周辺ツールが豊富

Ajax のテストの難しさ

Ajax(ここではjsでの通信だけでなく、DOM操作などひっくるめてAjaxと呼ぶ)をふんだんに使ってるようなアプリケーションは、XHRで通信を行うし、HTML も動的に変更される。
そのため、単純にはテストをすることが難しい。例えば、単純に Http通信をエミュレートするようなテストツールでは、Webアプリケーション全体をエンドツーエンドでテストしたとは言い難い。

Selenium は実際にブラウザを操作し、例えばボタンをクリックすると書いておけば、実際にブラウザ上で onclick のイベントが発生し、 JavaScript が動作するので、Ajaxのテスト、真にエンドツーエンドのテストができると言えよう。

ただし、Selenium といえどもコツはいる。例えば、「fooボタン」をクリックするとAjaxで通信が行われ、DOMが操作され、次なる「hogeボタン」が表示される。というアプリに対して

  • fooボタンをクリック
  • hogeボタンをクリック
  • hogeボタンを押した結果が正しいかをチェック

のようなテストシナリオを書いたとする。

このシナリオでは「fooボタン」をクリックしたあと、すぐに「hogeボタン」をクリックしようとしてしまうが、実際にはAjax通信したあとに、HTMLが生成され、それがDOMとして描画されないと「hogeボタン」をクリックすることができない。

なので、以下のようなシナリオである必要がある。

  • fooボタンをクリック
  • hogeボタンが表示されるのを待つ
  • hogeボタンをクリック
  • hogeボタンを押した結果が正しいかをチェック

"「hoge」ボタンが表示されるのを待つ" というシナリオが追加されている。

テストの価値について

結論から言おう。Seleniumのテストは書いてはいけない。"お前は何を言ってるんだ"って思った人たち。ちょっとゴタクにお付き合いください。

Seleniumのテストというのは書くのが大変で、かつ実行時間が長い、かつUIというのはすぐ変わる。自動テストというのは闇雲に書いていいものではない。より価値の高いテストを書くべきだ。
だから、基本はより価値の高い単体テストを先に充実させるべきだ。

モダンなWebアプリケーションフレームワークならば、擬似的にHttp通信の代わりとなるテストフレームワークが用意されいると思う。例えばRailsWicketCakePHPなどは、Controller層も含めてテストする仕組みが備わっている。単体テストの次はこれらのテストを書くべきだ。

また、JavaScript 側も、DOM操作などのブラウザ依存な部分を切り出し、非ブラウザ(DOM)依存の部分をQUnitなどのJavaScript単体テストを実行できるようにしておくべきだ。最近だと、node.js 上でテスト走らせるとかも流行りよねnodeunitとか、node + jasmine とか。

じゃあ Selenium はいつ書くべきなのか。

などに向いていると思われる。

余談だが、Seleniumみたいなブラウザ操作するテストは遅い。けど、JavaScriptがブザウザで動いてることとかもテストしたいという欲求を満たすために、HeadlessなテストをするPhantomJSのようなツールもある。
Headlessなテストができるツールとは、例えばWebKit のエンジンを使ってブラウザを直接起動しないんだけど、JavaScript+DOMなども含めたテストができるツールである。
マルチプラットフォーム、マルチブラウザがターゲットじゃなくてよければそういう解決もありだと思う。ただ、試してないけど、スクリプト書くの大変じゃないのかしら???

Selenium2.x とは

Selenium2.0 が 2011/07 にリリースされた。

今までのコードベースを大幅に変更し、WebDriverというAPI、実装を使った実装に変った。
WebDriver API を使うようになったので、テストのスクリプトコードも大きく変更されることになった。

リリースが最近のことなので、ネットを探してもあまり実際のテストスクリプトコードの情報が見当たらない。というのがこの記事を書いた動機の一つである。テストスクリプトの例を探すときは、「Selenium」でググる以外に「WebDriver」でググってもいいかもしれない。

Facebookのあいさつの返信機能をSeleniumから叩く

さて、本題。

Facebok のあいさつの返信機能を Selenium から叩いてみよう。
今回の例のコードはすべて github に上げてある。ちなみにスクリプトJava である。Rubyとかもそのうちかけたらかく。
https://github.com/yamashiro/facebook-poke

github がわかってる人はよろしく clone するなりなんなりするように。
わからない人は、上記 URL のページの「Downloads」から tar.gz なり zip をダウンロードして解凍する由。
動作する jar ファイルが入ってるので、結構重い。

Facebookのあいさつ(Poke)機能とは

次のように、Facebookで人のプロフィールを見ているときに「あいさつする」という機能がある。


これを使うと、その人のページに、あいさつしてくれた人一覧が表示される。

「あいさつを返す」というリンクをクリックすると、あいさつを返すダイアログがAjaxで開き、

「あいさつする」ボタンを押すと、あいさつが返され、結果ダイアログが一定時間表示される。

この操作をSelenium で実行する。

とりあえず実行

デフォルトは Firefox で動かすようにしてあるので、Firefoxをインストールおねがいしゃっす。
SeleniumIEChromeOperaなどにも対応してるがとりあえずFirefoxで動作させる。例えば Chrome のやり方はこちらを参照してコードをいじって欲しい。

Javaも必要なのでJavaもインストールしておねがいしゃっす。

また、Facebookのアカウントも必要です、また(必要なものばっかでサーセン)、これ結構大変かもしれませんが、誰かからあいさつされておいてください。
えっ。あいさつしてくれる友達いないって???複垢(ry

githubから手に入れたフォルダ(zipとかの人は解凍したフォルダ)の dist フォルダに移動してコマンドラインから以下を実行する。
この jar ファイルは maven が入っていれば mvn package で target 以下に生成することが可能であるので、独自処理とか足した場合は自分で package してほしい。

java -jar facebook-poke-1.0-jar-with-dependencies.jar -email <your_email> -password <your_password> 

あいさつ自体は実行したくないとかいう人は、

java -jar facebook-poke-1.0-jar-with-dependencies.jar -email <your_email> -password <your_password> -debug

というように-debugでデバッグモードで実行することで、あいさつの実行しないってことができるのでお試しあれぃ。

無事にブラウザが勝手に起動し、勝手にあれよあれよとメールアドレスの入力やパスワードの入力、あいさつが実行されれば成功である。

ソース解説

さて。本編中の本編。

Java のコードの全体は以下のURLにある。
https://github.com/yamashiro/facebook-poke/blob/master/src/main/java/example/selenium/facebook/pokes/PokesEraser.java

ソースを実行すると、main からよしなに pokesErase というメソッドが呼ばれる

	public void pokesErase(String email, String password,
			PokeStorategy pokeStorategy, AccountLaungage accountLaungage,
			DriverStorategy driverStorategy) throws PokeException {
		WebDriver driver = null;
		try {
			driver = driverStorategy.getDriver();
			driver.get("http://www.facebook.com/");

			// ログインしまっせ
			login(driver, email, password, accountLaungage);

			// あいさつしまくる
			returnPokes(driver, pokeStorategy, accountLaungage);

		} finally {
			if (driver != null) {
				driver.close();
			}
		}

	}

まず最初は WebDriver というのを初期化してる。

driver = driverStorategy.getDriver();

driverStorategy.getDriver()のコードは以下のようになっている。

	public WebDriver getDriver() {
		if (this == firefox) {
			return new FirefoxDriver();
		} else if (this == ie) {
			return new InternetExplorerDriver();
		} else {
			throw new IllegalStateException("hoge");
		}
	}

使用したいブラウザによって、生成するDriverの具象クラスを変更すれば、起動するブラウザを変更できる。

次に、Facebookのページをdriver.get で開いている。getは指定したURLを開くメソッドだ。

driver.get("http://www.facebook.com/");

loginメソッドでFacebookにログインしている。emailとかpasswordはコマンドライン引数で渡した値になる。

	private void login(WebDriver driver, String email, String password,
		AccountLaungage accountLaungage) throws PokeException {
		typeEmail(driver, email);
		typePassword(driver, password);
		doLogin(driver, accountLaungage);
	}
	private void typeEmail(WebDriver driver, String email) {
		WebElement elem = driver.findElement(By.id("email"));
		elem.clear();
		elem.sendKeys(email);
	}
	
	private void typePassword(WebDriver driver, String password) {
		WebElement elem = driver.findElement(By.id("pass"));
		elem.clear();
		elem.sendKeys(password);
	}
	
	private void doLogin(WebDriver driver, AccountLaungage accountLaungage)
			throws PokeException {
		WebElement elem = driver.findElement(By.xpath("//input[@value='"
				+ accountLaungage.gtLoginButtonValue() + "']"));
		elem.click();
	}

typeEmailと、typePasswordメソッドは、driver.findElementメソッドというHTML要素を探しだすメソッドを呼び出し、emailのinput要素やpasswordのinput要素を取得している。
その要素にたいして、sendKeysメソッドを呼ぶことで、emailなどを入力している。
doLoginメソッドで、ログインボタンの要素を取得し、click でログインボタンをクリックしている。

DriverやHTMLのタグを表すWebElementクラスの findElementメソッドには、要素を探すための By というクラスのオブジェクトを渡せば良い。
デフォルトで以下の By のオブジェクトを返すメソッドが用意されている。

例えば

<a class="hoge" name="foo" id="foo1">これはリンクです</a> 

というHTMLがあったときに以下のように検索できる。

  • By.className : 要素のクラス名で探す。 By.className("foo")
  • By.cssSelector : css セレクタで探す。 By.cssSelector("input.hoge") とか
  • By.id : id で探す。 By.id("foo1")
  • By.linkText : リンクテキストで探す。By.linkText("これはリンクです")
  • By.partialLinkText : リンクテキストの一部で探す。 By.partialLinkText("リンクです")
  • By.name : name で探す。By.name("foo")
  • By.tagName : タグ名で探す。By.tagName("a")
  • By.xpath : xpath で探す。これが一番柔軟に検索できる。By.xpath("//a[@class='hoge']")

これでログインができたので、いよいよ、面倒くさい挨拶を自動で返す!!!
returnPokes であいさつの一覧をさがし、あいさつを自動で返してる。

	private void returnPokes(WebDriver driver, PokeStorategy pokeStorategy,
			AccountLaungage accountLaungage) {
		if (false == exists(driver, By.id("pagelet_pokes"))) {
			// 挨拶のpageletがない場合は何もしない
			return;
		}

		WebElement pageletPokes = driver.findElement(By.id("pagelet_pokes"));

		// すべて表示があったら押す
		showAllIfNeed(pageletPokes);
		//WebDriverWait バージョンで wait する。showAllIfNeedWebDriverWaitVer(driver);

		int pokeCount = 0;
		List<WebElement> pokeElems = pageletPokes.findElements(By
				.xpath("//a[contains(@ajaxify, 'poke_dialog.php')]"));
		for (WebElement pokeElem : pokeElems) {
			if (pokeStorategy.isPoke(pokeCount)) {
				poke(driver, pokeElem, accountLaungage);
			}
			pokeCount++;
		}
	}

exists というメソッドはこのクラス内で定義されているHTML要素が存在するかを返すメソッドである。デフォルトでそういうのあればいいのに何故かない。

exists メソッドでは単に findElement して、例外が出たら存在しないので false を返している。

"pagelet_pokes" という id の要素は、あいさつをしてくれる友達がいない人には存在しないので、何もしない実装になっている。

	private boolean exists(SearchContext searchContext, By by) {
		try {
			searchContext.findElement(by);
			return true;
		} catch (NoSuchElementException e) {
			return false;
		}
	}

Facebookのあいさつに関する部分のHTMLは概ね以下のとおりになっている(2011/09現在)。

<div id="pagelet_pokes" data-referrer="pagelet_pokes">
(略)
	<div>
		<h4 class="uiHeaderTitle">あいさつ</h4>
	</div>
(略)
	<div class="phs">
(略)
	<li class="uiListItem  uiListVerticalItemBorder">
		<div class="lfloat fsm fwn fcg">
			<a href="http://www.facebook.com/sanemat">Aさん</a>
			<a ajaxify="/ajax/poke_dialog.php?uid=0001&amp;pokeback=1"
				rel="dialog-post">あいさつを返す</a>
			<a class="rfloat uiCloseButton uiCloseButtonSmall"
				ajaxify="/ajax/poke_hide.php?p=0001" href="#"
				rel="async-post" title="削除"></a>
		</div>
	</li>
	<li class="uiListItem  uiListVerticalItemBorder">
		<div class="lfloat fsm fwn fcg">
			<a href="http://www.facebook.com/sanemat">Bさん</a>
			<a
				ajaxify="/ajax/poke_dialog.php?uid=0002&amp;pokeback=1"
				rel="dialog-post">あいさつを返す</a>
(略)
	</li>
(略)
	<li class="showAll  uiListItem  uiListVerticalItemBorder"
		onclick="CSS.removeClass($(&quot;u970598_37&quot;), &quot;uiCollapsedListHidden&quot;); CSS.addClass($(&quot;u970598_37&quot;), &quot;uiCollapsedListVisible&quot;); return false;">
		<a href="#">
			<span class="fwb">すべて表示(13)</span>
		</a>
	</li>


全員に丁寧に心をこめてあいさつをしたいので、「すべて表示」をクリックして全員のあいさつを表示している。showAllIfNeed というメソッドがそれを実行している。

	private void showAllIfNeed(WebElement pageletPokes) {
		if (exists(pageletPokes, By.className("showAll"))) {
			WebElement elem = pageletPokes.findElement(By.className("showAll"));
			elem.click();
			// すべて表示を押して、Ajaxですべて表示がなくなるまで待つ
			waitInvisible(pageletPokes, By.className("showAll"));
		}

	}

ここで、初めて Ajax な処理を Selenium で実行、待機している。

<li class="showAll  uiListItem  uiListVerticalItemBorder"
		onclick="CSS.removeClass($(&quot;u970598_37&quot;), &quot;uiCollapsedListHidden&quot;); CSS.addClass($(&quot;u970598_37&quot;), &quot;uiCollapsedListVisible&quot;); return false;">

pageletPokes.findElement(By.className("showAll"))で、上記「すべて表示」の要素を探し、クリックしている。

Ajaxな処理なのでクリックしただけではすぐに全ての「あいさつが表示された」状態ではない。全ての「あいさつが表示された」状態は、「すべて表示」の要素が非表示になった時である。

なので、ここでは

  • 「すべて表示」要素を探す
  • 「すべて表示」要素をクリック
  • 「すべて表示」が非表示になるのを待つ

という処理を行なっている。

次に要素の非表示を待機している waitInvisible メソッドを見てみよう。

	// 非表示になるまでまつ
	private void waitInvisible(final SearchContext context, final By by) {
		Wait wait = new Wait() {
			@Override
			public boolean until() {
				WebElement elem = context.findElement(by);
				return false == elem.isDisplayed();
			}
		};
		wait.wait("Element exists", WAIT_SECOND * 1000);
	}

Selenium2.0 には Wait というクラスが用意されている。
これは until が true である間、指定した実行時間内(WAIT_SECOND * 1000)、実行し続ける。

この場合、要素を取得して、それが表示されてる間[false == elem.isDisplayed()]は処理を実行しつづける。
逆に言うと、表示されなくなったら終了する。
これにより、「すべて表示」の要素が表示されなくなるまで待つことができる。

Selenium2.x で Ajax 用に処理を待機する方法は何パターンかあるが、こちらの記事が詳細に書かれているので参考にするとよい。
リンク先の記事にもあるが、Selenium2.6以降を使っているならば、WebDriverWaitとExpectedConditionsを組み合わせる方法がおすすめである。そのバーションの waitInvisible も書いてあり以下に示すので参考にしてほしい。

	//非表示になるまで待つ。
	private void waitInvisible(WebDriver driver, By by) {
		org.openqa.selenium.support.ui.Wait<WebDriver> wait = new WebDriverWait(driver, WAIT_SECOND);
		wait.until(invisibilityOfElementLocated(by));
	}

ね。こっちのほうが簡単でしょう?

Ajaxを待つ基本パターンは解説したので、残りの処理は手短に解説する。
returnPokesの、showAllIfNeed呼び出し以降のメソッドの説明をする。

		int pokeCount = 0;
		List<WebElement> pokeElems = pageletPokes.findElements(By
				.xpath("//a[contains(@ajaxify, 'poke_dialog.php')]"));
		for (WebElement pokeElem : pokeElems) {
			if (pokeStorategy.isPoke(pokeCount)) {
				poke(driver, pokeElem, accountLaungage);
			}
			pokeCount++;
		}

pageletPokesはすでに取得した以下の要素である。

<div id="pagelet_pokes" data-referrer="pagelet_pokes"> 

「pageletPokes.findElements(…)」では上記要素の子要素で、@ajaxifyという属性に、poke_dialog.php がある要素、つまり、以下のような「あいさつを返す」リンク要素を探している。

<a ajaxify="/ajax/poke_dialog.php?uid=0001&amp;pokeback=1" rel="dialog-post">あいさつを返す</a>

次に、それぞれの「あいさつを返す」要素ループをまわしpokeメソッドを呼び出し、あいさつを返している。pokeStorategy については詳細解説しないが、あいさつする人数を制限する機能が実装されている。

次に、実際のあいさつ返しを行う poke メソッドを解説する。

	private void poke(WebDriver driver, WebElement pokeElem,
			AccountLaungage accountLaungage) {
		// 「あいさつを返す」要素をクリックする
		pokeElem.click();

		// あいさつをするボタンが出るのを待つ
		By pokeButtonBy = new ByValue(accountLaungage.getPokeButtonValue());
		if (DEBUG) {
			pokeButtonBy = By.name("cancel");
		}
		waitPresent(driver, pokeButtonBy);

		WebElement button = driver.findElement(pokeButtonBy);
		button.click();
		// ボタンがなくなってる(Ajaxで処理が終わったことを確認)
		waitNotPresent(driver, pokeButtonBy);
		
		//なんかPokeした後にOKボタンのダイアログが出て、自動で消えるとかいう糞仕様…OKボタンが消えるタイミングも微妙だし…待つしかない…
		waitNotPresent(driver, By.name("ok"));
	}

「pokeElem.click()」で「あいさつを返す」リンクをクリックする。
クリックした結果、Ajax でダイアログが開き「あいさつをする」ボタンが出てくるはずなので、waitPresentメソッドで、ボタンの出現を待つ。waitPresentメソッドとwaitNotPresentの実装は各自確認して欲しい。

「あいさつをする」ボタンが表示されたらそれをクリックする。「あいさつをする」もAjaxで、その呼出しが完了したことは、「あいさつをする」ボタンがなくなってることをwaitNotPresentメソッドを呼び出したことで確認している。
さらに、あいさつをしたあと「OK」ってダイアログが出て、自動で消えるのでそれを待ってから、次の人へのあいさつが行われるようにしている。

ByValue クラスについて詳細には説明しない。Byクラスのサブクラスのサンプルとして用意してある。この例だと value 属性で検索できるようにした。興味のあるかたは実装を覗いてみてほしい。

雑多な何か

インストロール

SeleniumJavaで使うには、maven派の人だったら pom に、

		<dependency>
			<groupId>org.seleniumhq.selenium</groupId>
			<artifactId>selenium-java</artifactId>
			<version>2.6.0</version>
		</dependency>

を入れればおk


そうじゃない人は、selenium-javaのzipをダウンロードしてクラスパスに含めればおk。
http://code.google.com/p/selenium/downloads/list

キャプチャ

Seleniumの利点はCSSとかデザインのチェックもできると思ってるんだよね。
なぜなら、Seleniumはブラウザで表示した画像をキャプチャできるので、例えば毎日画像をチェックして、
大きく変更されてないかとかチェックできるよ!

ブラウザによって大きく違わないかとか!

どうチェックするかは、あとはキャプチャした画像をどう使うかだからお前らが考えろ!

まとめ的ななにか

このようにして SeleniumAjax な Webアプリケーションを実行することができる。
今回は実行しかしてないが、JUnit と組み合わせることで、HTMLの内容やデータベースの内容を Assert して自動テストすることができる。

例えば、僕は某ブラウザゲームの自動実行ツールなどを作成したことがある。dragAndDropなどもできるので、工夫すればSeleniumにいろいろ実行させることができる。

ただし、上記のようにスクリプトファイルは難しいし、xpathなどコツはいる。

Selenium でテストを書くときは、Seleniumでテストを書きやすいように id をふったりする必要があるだろう。Ajaxのアプリケーションの場合はテスト用に変更をマークするようなhiddenとかがあっても良いと思う。

では良いテストライフを!