スポンサーリンク

ブラウザの自動操作の最大の問題,「タイムアウト」を克服するには

ブラウザの自動化を妨害する最後の壁,それはタイムアウト

Webページのロード時に,ブラウザのビジー状態が解除されず,いつまでも次のステップに進めなくなってしまう現象のこと。



どんなツールを使っても,タイムアウトは必ず発生する。

自動化ツールをたくさん使い込むほど,その問題は浮上しやすい。


例えば,

Selenium:

open()でタイムアウトが発生する
http://d.hatena.ne.jp/w650/20071031/p1

Selenium 注意報:やたらタイムアウトが発生する
http://blog.koshigoe.jp/archives/2006...

VBA:

WEBクエリ取得中のまま固まってしまう
http://www.vbalab.net/vbaqa/c-board.c...

※ほかに,UWSCやWSH,また拡張ツールであるExceleniumやIE AutoTesterなどもある。



とくに,Webアプリケーションの開発作業では,

  • 数人以上の開発メンバが,
  • 数千ステップのブラウザ操作テストスクリプトを,
  • 毎日,
  • 一日に何回も実行する。

という状況もある。*1


この場合,ブラウザ上でのリクエストタイムアウトは,「もしかしたら」の問題ではなく,「いつ」「何分おきに」の問題になる。




この「タイムアウト問題」の解決策として,

本稿では,以下のようなコードを提案する。

// 処理1〜処理3の動作を保証します。

ie.transaction( function( ie ){

	// 処理1

	// 処理2

	// 処理3

} );

	// ↑ もし途中でタイムアウトが起きたら,
	//  「transaction」の中身全体を,自動的にやり直してくれる。

言語はJavaScript(WSH/JScript)。

なぜなら,

  • (1)DOM操作のために利用される標準の言語であること。(Webアプリなので)
  • (2)クロージャが使えること。(再試行したい「処理」そのものを,容易に関数の引数に取れること)
  • (3)高級な制御構造を利用できること。(ループ処理などを簡潔に記述する事が出来る)
  • (4)ファイルシステムに容易にアクセスできること。(ログを吐きたい等の場合もあるから)
  • (5)企業ユーザの主要なシェアを占めるブラウザ(=IE)のAPIに,容易にアクセスできること。
  • (6)企業ユーザの主要なシェアを占めるOS上で,特別なツールを必要とせずにすぐに動作すること。

のすべてを満たす,唯一の言語だからだ。



基盤となるライブラリは下URLのエントリを参照。

プロジェクト専用のDSLで,効率的にIEを自動操作する(WSH/JScript)
http://language-and-engineering.hatenablog.jp/entry/20100310/p1


以下に

  • このアプローチを取る理由
  • サンプルコード

を記載する。



タイムアウト対策の種類

まず,ブラウザ自動操作時のタイムアウトに対して,一般にどのようなリアクションが存在するかという点を述べる。

(1)wait処理を一切しないので,タイムアウトも一切気にしない。

最も原始的な段階では,ブラウザ自動操作時に,まともなwait処理を行わない。


想定されるシナリオ:

画面上の「送信」ボタンなどをクリックしたら,

テストスクリプトの次のステップを実行するタイミングにおいて,確実に,

次の画面の表示が完了している。

これは考えづらい前提条件だ。


そのようなケースは,

  • 対象アプリがよっぽど単純である(遅延を考慮しなくて済む)か,
  • テストの効率がよっぽど悪い(”テスト側の遅延をひどくすれば,アプリ側の遅延がひどくてもカバーできる”)か,

のどちらかに分類されると思われる。



テストスクリプト中に,「3秒待つ」のような,その場しのぎのコードを付加する人もいる。

しかし説明するまでもなく,これは根本解決になっていない。

多くの場合,その人が「機能の仕様や画面の仕様を把握できていない」ことの表れでもある。


つまり,

  • 何をもって処理完了となるのか(機能仕様)
  • アクタによる画面利用の前提条件(=ロード完了とみなすタイミング)は何か(画面仕様)

といった点の把握や吟味ができていない可能性がある。


参考:

SeleniumでAjaxアプリケーションをテストする(小休止をはさむとどうなるか)
http://www.infoq.com/jp/articles/test...


やはり,通常のWebアプリケーション開発では,「パフォーマンス要件の範囲内で,任意秒数の遅延が発生する」という前提を無視することはできない。


(2)wait処理を行なうので,タイムアウトが発生する。あきらめるか,手動で慌ててリカバリする。

任意秒数の遅延に対して対策を行なう場合,

  • 「ブラウザが特定の状態になるまでsleepする」

という処理が,テストコード側の随所に現れることになる。


ここで言う特定の状態とは,たとえば,

  • (1)ブラウザのビジー状態が解除されること。(Seleniumで言えば,**AndWaitが相当)
  • (2)特定のDOM要素が存在すること。(〃,WaitForElementPresent)
  • (3)特定のDOM要素の特定の属性が,特定の値であること。(〃,WaitForAttribute)
  • (4)JavaScriptコードの実行結果が,特定の値であること。(〃,WaitForCondition)

など。

その条件が満たされるまで,テストコードは待機状態になり,
次のステップは実行されない。



多くの場合,対策が施されるのは,ここまでである。


上に挙げた(2),(3),(4)は,アプリケーション側とテストコード側の双方で,適切に作り込みを行なう事によって実現できる。

例えば,「目印」となるDOM要素を画面上に設置して,
「テストしやすい」画面にするなど。

※そして「テストしやすい」とは,「他のAPIから呼び出しやすい」という事と等価だ。

良い設計とは「テストしやすい」ことである
http://blogs.itmedia.co.jp/hiranabe/2...

しかし(1)の,ブラウザのビジー状態だけは,対策しきれない。

アプリ側でどう工夫しても,テスト側でどう工夫しても,必ず起こる。


パケットが絶対の信頼性を持っていないHTTPというインフラの上に築いているアプリケーションなので,いつか必ず,

ブラウザのプログレスバーが止まったままなんですけど・・・・・

という声が上がることになる。


※開発者は本番用のサーバではなく,開発用の貧弱なサーバを使ってテストする事の方が多いだろう。その点も,タイムアウト発生の原因の一つと言える。



そうすると,通常のテストツールでは,あきらめるしかない。

もともとタイムアウト秒数(例えば30秒)を設定しておいて,
待機時間がその秒数を超えたら,タイムアウトしましたという記録が残る。

そして,テストログのビューアは赤くなる。



根性で,

  • プログレスバーが止まったら早めにF5を押してね!
  • プログレスバーが止まったら,該当フレームを右クリック→最新の情報に更新 でよろしく!

という手順が課され,それを遵守するために,テスト実施者がモニタを凝視し続ける,ということもある。

(そしてF5を押した結果間違えてSeleniumの管理パネルもリロードされてしまい,テストが振り出しに戻る。)



でも,それは自動化の恩恵にあずかっていると言えるのだろうか?

ロボットが自動タスクをこなしている間,人間がすべきことは,

ロボットにできない,もっと別の価値あるタスクなのではないか?



(3)タイムアウト時は,対象画面を,自動リロードする。

タイムアウトによる無限Sleepを自明の現象として認めると,次に行なえる対策は,自動リロードだ。


コードの具体例として,以前のバージョンの lib_ie.js を見てみる。

	
	// ビジー状態の間待つ
	wait_while_busy : function(){
		var timeout_ms      = 10000;
		var step_ms         = 100;
		var total_waited_ms = 0;
		
		while( this.is_busy() )
		{
			this.sleep( step_ms );
			
			// タイムアウトか?
			total_waited_ms += step_ms;
			if( total_waited_ms >= timeout_ms )
			{
				this.debug(
					"警告:タイムアウトのため,リロードします。("
					+ this.get_current_url()
					+ ")"
				);
				this.reload();
				
				break;
			}
		}
		
		this.sleep( 500 );
	}
	,

タイムアウトしたら,あきらめてエラーを発生させるのではなく,
ブラウザにreload (もしくはRefresh)をかける。

そうすれば,プログレスバーが止まったままになっているその画面は,再度読み込まれる。



たいてい,2度目の読み込みはタイムアウトが発生せず,
テストコードの処理は次のステップへ継続する事ができる。

(注:もし何度リロードしても毎回タイムアウトが発生するようであれば,それはブラウザやネットワークの気まぐれではなく,れっきとしたアプリ側のバグだろう。例えばサーバサイドで無限ループが発生しているとか,ファイル読み込みの順序がボトルネックになっているなど。)



しかし,これだとPOSTリクエストに対応できない。

reloadでスムーズに再現できるのは,GETリクエストのみなのだ。

情報を再送信しないとページを表示できません。メッセージについて
http://www.microsoft.com/japan/msdn/c...

  • location.href=URL というコードを使えば,パラメータなしのGETが再現できる。


ウィンドウがリロードされるとIEのダイアログで
「情報を再送信しないと。。。」といった再試行の表示がされて
しまうのですが、これを強制的に行う処理はできますでしょうか?
http://otd8.jbbs.livedoor.jp/javascri...

  • formのmethodをpostからgetに変えればメッセージは出なくなります。


大量のデータを送受信するために使われるのはGETではなくPOSTであり,これは,再現が厳しい。


「ブラウザが送ったPOSTデータを,タイムアウト発生時に,自動的に再現する」という戦略は実現困難なのだ。

三流解説IE:.Navigateメソッド とは
http://ie.vba-ken3.jp/Methods/Navigate/

  • ieの操作APIのNavigateメソッドには,POSTパラメータを含めることが可能。
  • 問題は,その送るべきパラメータを,どこから持ってくるか。


POSTやGETの値の取得方法
http://oshiete1.goo.ne.jp/qa4430815.html

  • location.searchで取得できるのはGETのみ


POSTで表示されるページをリロードしようとすると,IEでは

このページを再表示するには、以前送信した情報を再送信する必要があります。


※英語では
To display the webpage again, Internet Explorer needs to resend  the information you've already submitted.

のダイアログが出てしまい,テストコードの実行の流れがそこで止まってしまう。

POSTを多用しているアプリケーションだと,この「リロード戦略」は使えない,という事になる。


(4)タイムアウト時は,一連の処理を,自動的に再試行する。

そこで,今回提案するのが以下のライブラリ。

要となるのは「transaction」というメソッド。*2

また,「transaction」の利用次第で動作内容が変わる,wait_while_busy() の内容も要点になる。


失敗したくない一連のPOSTリクエストは,この transaction という関数の中に渡すようにする。


そうすれば,もしタイムアウトエラーが発生した時には,
ページをリロードするのではなく,
一連の処理範囲を初めから再試行する。


transactionの外部でタイムアウトエラーが発生した場合は,
前項と同じく,対象ページのみがreloadされる。



lib_ie.js

// IE自動操作のためのクラス

var IE = function( obj ){
	this._build( obj );
};
IE.prototype = {
	// ブラウザオブジェクト
	_ie : null,
	
	
	// トランザクションの試行回数の限度設定値
	action_trial_limit : 5,
	
	
	// トランザクションレベル
	// (現在実行中の処理が,トランザクションの階層の何段目に位置するか)
	_transaction_stack_level : 0,
	
	
	
	// ---------- セットアップ系 ----------
	
	
	// 初回セットアップ
	_build : function( obj ){
		this._create_new_ie();
		
		// 最初のURLを開く前に可視にしておかないと,
		// 最初のURLでタイムアウトした時に困る
		this.set_visible( true );
		this.debug( "IEを起動しました。" );
		
		var default_url = "about:blank";
		if( obj && obj[ "url" ] )
		{
			default_url = obj[ "url" ];
		}
		this.goto_url( default_url );
	}
	,
	
	// 新規IEをセット
	_create_new_ie : function(){
		this._ie = WScript.CreateObject( "InternetExplorer.Application" );
	}
	,
	
	// 可視状態
	set_visible : function( bool ){
		this._ie.Visible = bool;
	}
	,
	
	// 終了
	quit : function(){
		this._ie.Quit();
		this._ie = null;
	}
	,
	

	// ---------- システム待機系 ----------

	
	// ビジー状態の間待つ
	wait_while_busy : function(){
		var timeout_ms      = 10000;
		var step_ms         = 100;
		var total_waited_ms = 0;
		
		while( this.is_busy() )
		{
			this.sleep( step_ms );
			
			// タイムアウトか?
			total_waited_ms += step_ms;
			if( total_waited_ms >= timeout_ms )
			{
				// トランザクション内か?
				if( this.is_inside_transaction() )
				{
					// トランザクション全体を初めからやり直す
					throw "timeout";
				}
				else
				{
					// トランザクション内でなければ,
					// 今開こうとしている画面だけをリロードする
					this.warn(
						"タイムアウトのため,リロードします。("
						+ this.get_current_url()
						+ ")"
					);
					this.reload();
					
					break;
				}
			}
		}
		
		this.sleep( 500 );
	}
	,
	
	// ビジー状態か判定
	is_busy : function(){
		return ( ( this._ie.Busy ) || ( this._ie.readystate != 4 ) );
	}
	,
	
	// 指定ミリ秒だけ待機
	sleep : function( ms ){
		WScript.Sleep( ms );
	}
	,
	
	// 動作の保証区間(トランザクション)を定義
	transaction : function( func )
	{
		var trial_count = 1; // 試行回数カウンタ
		var trial_limit = this.action_trial_limit;
		var loop_continue_flag = true;
		
		// トランザクションに入る
		this._transaction_stack_level ++;
		
		while( loop_continue_flag )
		{
			try{
				// 目的動作を実行する
				func( this );
				
				// 例外もなく無事に実行が終わったので,
				// while節から抜けてよい
				loop_continue_flag = false;
				
				// 成功を通知
				if( trial_count > 1 )
				{
					this.debug(
						trial_count
						+ "回目の試行が成功しました。("
						+ this.get_current_url()
						+ ")"
					);
				}
			}
			catch( err )
			{
				// func内でタイムアウト例外が発生したか?
				if( err == "timeout" )
				{
					this.debug(
						trial_count
						+ "回目の試行がタイムアウトしました。("
						+ this.get_current_url()
						+ ")"
					);
					
					// 試行回数カウンタを増やす
					trial_count ++;
				}
				else
				{
					throw err; // タイムアウト以外の例外は伝播させる
				}
			}
			
			// 試行回数を使い果たしたか?
			if( trial_count > trial_limit )
			{
				this.fatal( "タイムアウトに対する試行回数が上限に達しました。" );
				throw "timeout";
			}
			
			// ループの終了許可が下りない限り,このwhile部は実行され続ける
		}
		
		// このトランザクションを抜ける
		this._transaction_stack_level --;
	}
	,

	// トランザクション内かどうかを判定
	is_inside_transaction : function(){
		return ( this._transaction_stack_level > 0 );
	}
	,



	// ---------- アドレスバー関連 ----------



	// ページを移動
	goto_url : function( url ){
		this._ie.Navigate( url );
		this.wait_while_busy();
	}
	,
	
	// ページをリロード
	reload : function(){
		this._ie.document.location.reload( true );
			// http://www.microsoft.com/japan/technet/scriptcenter/resources/qanda/sept05/hey0927.mspx
		this.wait_while_busy();
	}
	,
	
	// 現在表示中のページのURL
	get_current_url : function()
	{
		return this._ie.LocationURL;
			// http://blog.livedoor.jp/programlog/archives/298228.html
	}
	,
	
	// 受け取ったJavaScript関数オブジェクトをブラウザ上で実行します
	exec_js : function( func )
	{
		var str_address = "javascript:(" + func.toString() + ")();void(0);";
			//this.debug( str_address );
			// toSource()のかわりにtoString()を
			// http://blog.livedoor.jp/dankogai/archives/50957994.html
		
		this._ie.Navigate( str_address );
	}
	,


	// ---------- DOM操作 ----------

	
	// セーフな要素取得
	$ : function( dom_id ){
		
		// 10秒までは待ってあげる
		this.wait_for_element_present( dom_id, 10000 );
		
		var ret = this.gid( dom_id );
		if( ret == null )
		{
			this.warn( dom_id + "がnullです。" );
		}
		
		return ret;
	}
	,
	
	
	// IDで要素取得
	gid : function( dom_id ){
		return this._ie.document.getElementById( dom_id );
	}
	,
	
	
	// 存在判定
	is_element_present : function( dom_id ){
		return ( this.gid( dom_id ) != null );
	}
	,
	
	
	// 存在待ち
	wait_for_element_present : function( dom_id, ms_timeout )
	{
		if( ! ms_timeout )
		{
			ms_timeout = 10000;
		}
	
		var ms_spent = 0;
		while( true )
		{
			// 要素が現れたか?
			if( this.is_element_present( dom_id ) )
			{
				break;
			}
			else
			{
				ms_spent += 100;
				this.sleep( 100 );
			}
			
			// タイムアウトか?
			if( ms_timeout <= ms_spent )
			{
				this.fatal( dom_id + "が存在しません。" );
				break;
			}
		}
		// ブラウザのビジー状態とは異なるので,
		// ここにはトランザクションを適用しない。
		
		return;
	}
	,
	
	// 状態待ち
	wait_for_condition : function( func, ms_timeout )
	{
		if( ! ms_timeout )
		{
			ms_timeout = 10000;
		}
		
		var time_spent = 0;
		while( true )
		{
			// 状態が実現したか?
			if( func.apply( this ) )
			{
				this.sleep( 100 );
				break;
			}
			else
			{
				ms_spent += 100;
				this.sleep( 100 );
			}
			
			// タイムアウトか?
			if( ms_timeout <= ms_spent )
			{
				this.fatal( func.toString() + "の結果がtrueになりませんでした。" );
				break;
			}
		}
		
		return;
	}
	,
	
	
	// 入力
	type : function( dom_id, value ){
		this.$( dom_id ).value = value;
	}
	,
	
	// クリック
	click : function( dom_id ){
		this.$( dom_id ).click();
	}
	,

	// クリックして待機
	click_and_wait : function( dom_id ){
		this.click( dom_id );
		this.wait_while_busy();
	}
	,


	// セレクトボックス(文言ベース)
	select_by_label : function( dom_id, target_label )
	{
		var opts = this.$( dom_id ).options;
		for( var i = 0; i < opts.length; i ++ )
		{
			if( "" + opts[i].innerText == "" + target_label )
			{
				opts[i].selected = true;
			}
		}
		this.$( dom_id ).fireEvent( "onchange" );
	}
	,


	// ---------- デバッグ用 ----------


	echo : function( str )
	{
		WScript.Echo( str );
	}
	,
	
	debug : function( str )
	{
		this.echo( "[" + this.timestamp() + "] " + str );
	}
	,
	
	warn : function( str )
	{
		this.debug( "警告:" + str );
	}
	,
	
	fatal : function( str )
	{
		this.debug( "エラー:" + str );
	}
	,
	
	// 現在時刻
	timestamp : function()
	{
		var d = new Date();
		return [
			d.getYear(),
			this.dig2( d.getMonth() + 1 ),
			this.dig2( d.getDate() )
		].join("/") + " " + [
			this.dig2( d.getHours() ),
			this.dig2( d.getMinutes() ),
			this.dig2( d.getSeconds() )
		].join(":");
	}
	,
	
	// 2桁に0埋め
	dig2 : function( num )
	{
		if( ( 0 <= num ) && ( num < 10 ) )
		{
			return "0" + num;
		}
		else
		{
			return "" + num;
		}
	}

};


以下は,このライブラリを利用するサンプルコード。


lib_site.js

// プロジェクトに特化したメソッドを記述します。


// トップページを開く
function open_top_page()
{
	ie.goto_url( "http://www.yahoo.co.jp" );
}

// キーワード検索
function search_by_keyword( kwd )
{
	ie.transaction(function(ie){
		open_top_page();
		
		ie.type( "srchtxt", kwd );
		ie.click_and_wait( "srchbtn" );
		
		ie.sleep( 1000 );
	});
}

ie.transactionの利用に注目。

検索結果画面を開く時にタイムアウトが発生したら,トップ画面を開く所からやり直すようにしてある。



exec.js

var ie = new IE();

// 検索実行
ie.transaction(function(ie){
	search_by_keyword( "hoge" );
	search_by_keyword( "fuga" );
	search_by_keyword( "boo" );
});

ie.debug("終了しました。");
ie.quit();


ie.wsf

<job>
	<script language="JavaScript" src="lib_ie.js"></script>
	<script language="JavaScript" src="lib_site.js"></script>
	<script language="JavaScript" src="exec.js"></script>
</job>


実行.bat

@cscript //nologo ie.wsf
@pause

このbatファイルをダブルクリックすれば,IEの操作が始まる。



途中で,LANケーブルをいじったりしてブラウザにタイムアウトを発生させてみよう。

コマンドプロンプト上に表示されるログ:

[2010/04/03 15:29:07] IEを起動しました。
[2010/04/03 15:29:41] 1回目の試行がタイムアウトしました。(http://www.yahoo.co.jp/)
[2010/04/03 15:30:00] 2回目の試行が成功しました。(http://search.yahoo.co.jp/search?p=fuga&search.x=1&fr=top_ga1_sa&tid=top_ga1_sa&ei=UTF-8&aq=&oq=)
[2010/04/03 15:30:05] 終了しました。
続行するには何かキーを押してください . . .

トップページを開く部分でタイムアウトが発生したが,「トランザクション」の内部だったので,一連の処理全体が再試行されている。

そして2回目では成功している。

補足

リトライ処理を気楽に使えるように隠ぺいしただけだが,これで問題をかなりカバーできる。



 

*1:もちろん,可能な限りブラウザテストではなく単体テストによってシナリオをカバーすべきだ。

*2:ロールバックという概念がないので,データベースのトランザクションとは異なる。