Seleniumで画面描画を待つ方法


## アイテムの取得に失敗する

Seleniumで画面上のアイテムを取得したい時に、まだ表示が完了してなくて取得に失敗することがあります。Thread.sleep()で待ってもいいのですが、待機時間が固定でないのにThread.sleep()なんていやですよね。表示されるまでの時間は環境に依存するからまた変更が必要になるかもしれないし、無駄に待つ事が多いとスローテストになってしまいます。表示されたらできるだけ早く取得したいです。

## eventuallyで「例外を生成しなくなるまで待つ」

ScalaTestというテストライブラリにeventuallyというメソッドがありました。こんな風に使えます。

```scala:eventually
val button = eventually { find("buttuon_id").get }
```

eventuallyの中の処理で例外が発生すると、中の処理が最初から実行されます。この例ではfind("button_id")で取得に失敗するとNoneが返るので、getが呼び出されたときに例外が発生し、もう一度findするわけです。

今回はこれをJavaで実装していきます。メインとなるメソッド部分です。

```java
	public static <R> R eventually(Supplier<R> f, Duration timeout, Duration interval) {
		long start = now();
		long end = start + timeout.toMillis();
		Throwable lastError = null;
		while (now() < end) {
			try {
				return f.get();
			} catch (Throwable t) {
				lastError = t;
			}
			try {
				Thread.sleep(interval.toMillis());
			} catch (InterruptedException e) {
			}
		}
		val timeStr = PeriodUtils.prettyFormat(now()-start);
		val message = MESSAGE_TEMPLATE.replace("$time", timeStr).replace("$message",lastError.getMessage());
		throw new RuntimeException(message, lastError);
	}
	private static final String MESSAGE_TEMPLATE = "Eventually failed over $time. Last mejssage:\n$message";
}
```

Rは戻り型です。「Supplier&lt;R&gt; f」は「引数無しでR型の値を返す関数」です。この関数が成功したらそのまま値を返しますが、例外をキャッチするとタイムアウトになるまで繰り返し呼びます。タイムアウトに到達すると「Eventually failed over 1m2s20ms. Last mejssage: button_id is not found」のようなメッセージを持った例外を生成します。

2番目と3番目の引数はそれぞれタイムアウトとインターバルです。

### 全ソース

戻り型voidやタイムアウト指定のないバージョンなどを含んだものが下記です。コード補完でeventuallyメソッドをすぐにstatic importできるように、eclipseであれば「Java - Editor - Content Assist - Favorites」に「domain.Eventually.*」を、「Java - Code Style - Organize Import」の「Number of static imports needed for .*」を「1」にしておくと便利です。

```java:Eventually.java
import java.time.Duration;
import java.util.function.Supplier;

import lombok.val;

public class Eventually {
	private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(10);
	private static final Duration DEFAULT_INTERVAL = Duration.ofSeconds(3);
	private static long now() {
		return System.currentTimeMillis();
	}

	public static void eventually(Runnable f) {
		eventually(f, DEFAULT_TIMEOUT, DEFAULT_INTERVAL);
	}
	
	public static void eventually(Runnable f, Duration timeout) {
		eventually(f, timeout, DEFAULT_INTERVAL);
	}

	public static void eventually(Runnable f, Duration timeout, Duration interval) {
		eventually(() -> {
			f.run();
			return null;
		}, timeout, interval);
	}

	public static <R> R eventually(Supplier<R> f) {
		return eventually(f, DEFAULT_TIMEOUT, DEFAULT_INTERVAL);
	}
	
	public static <R> R eventually(Supplier<R> f, Duration timeout) {
		return eventually(f, timeout, DEFAULT_INTERVAL);
	}
	
	public static <R> R eventually(Supplier<R> f, Duration timeout, Duration interval) {
		long start = now();
		long end = start + timeout.toMillis();
		Throwable lastError = null;
		while (now() < end) {
			try {
				return f.get();
			} catch (Throwable t) {
				lastError = t;
			}
			try {
				Thread.sleep(interval.toMillis());
			} catch (InterruptedException e) {
			}
		}
		val timeStr = PeriodUtils.prettyFormat(now()-start);
		val message = MESSAGE_TEMPLATE.replace("$time", timeStr).replace("$message",lastError.getMessage());
		throw new RuntimeException(message, lastError);
	}
	private static final String MESSAGE_TEMPLATE = "Eventually failed over $time. Last message:\n$message";
}
```

使用するときはこんな感じになります。

```java
eventually(() -> find("buttun_id"));
```

### prettyFormat

ここでは本題でないので軽く流しますが、ミリ秒のフォーマットは下記のように行っています。今ならTimeUnitを使ってもっとマシに書けそう。

```java:PrettyFromat.java
	public static String prettyFormat(long period){
		if(period==0){
			return "0ms";
		}
		StringBuffer sb = new StringBuffer();

		sb.append(makePrettyString(period, 1000, "ms"));
		period/=1000;
		sb.insert(0,makePrettyString(period, 60, "s"));
		period/=60;
		sb.insert(0,makePrettyString(period, 60, "m"));
		period/=60;
		sb.insert(0,makePrettyString(period, 24, "h"));
		period/=24;
		sb.insert(0,makePrettyString(period, 0, "d"));
		return sb.toString();
	}

	private static String makePrettyString(long src, long unit, String unitStr){
		if(src==0){
			return "";
		}
		if(unit==0){
			return src+unitStr;
		}
		long unitTime = src%unit;
		if(unitTime==0){
			return "";
		}
		return unitTime+unitStr;
	}
```

## readySteate=completeまで待つ

htmlでは画面描画完了後にjavascriptを走らせるためdocument.ready()を使っているケースが多いかと思います。これに相当するのが「document.readyState==complete」です。
http://www.w3schools.com/jsref/prop_doc_readystate.asp
SeleniumではJavascriptが実行できるため、上記のeventuallyと合わせて以下のように書くことが出来ます。

```java
public void waitTilReady() {
	eventually(() -> assertEquals(executeScript("return document.readyState"), "complete"));
}
```
いろいろなJavacriptが実行できるように共通メソッドにしておきます。SeleniumからはできるだけJavascriptを利用したくないところではありますが、システムを作った方は自動テストを想定していないので力技が必要な時もあります。悲しいですね。

```java
public Object executeScript(String script, Object... args) {
	try{
		JavascriptExecutor executor = (JavascriptExecutor)driver;
		return executor.executeScript(script, args);
	} catch(UnsupportedOperationException ex) {
		throw ex;
	} catch(Exception ex) {
		throw new UnsupportedOperationException("Web driver " + driver.getClass().getName() + " does not support javascript execution.");
	}
}
 ```

### Web要素が見つかるまで待つ

waitTilReady()後にWebElementを取得しようとしても空振りするときがあります。ajaxなどで画面描画している場合はreadyStateがcompleteになってからElementを生成したりするので仕方ないです。そういう時は欲しい要素が取れるまでeventuallyしましょう。

```java:
eventually(()->driver.findElement(By.id("button_id")));
```
driver#findElement(by)は見つからない場合に例外を投げるので、これで取得できるかタイムアウトになるまで何度でもトライしてくれます。

実際にはScalaTestと同じように

```java:
eventually(()->find("button_id"));
```
これだけでid->name->cssと順に探すようにしました。Test用ベースクラスを作ってfindメソッドで順に探すようになっています。テスト書くのが大分楽になりますね。