株式会社クリアコード > ククログ

ククログ

タグ:

設計判断事例の紹介:E4Xの事実上の廃止を受けての、UxUの開発方針の検討

設計判断とは何か?

ソフトウェアの開発においては、仕様を決める時に、並立できない複数の選択肢の中からどれか1つを選ばなければならないことがあります。特に設計に関わる場面での判断のことを、一般的に「設計判断」と言います。「この新機能を加えるのか、加えないのか」「この新形式をサポートするのか、しないのか」などのような、「やるか、やらないか」の判断はその典型です。

設計判断においては、常に「やる」という判断を下せるとは限りません。様々な事情から「やる」という選択肢を選べないこともありますし、あるいは何らかの明確な理由があって積極的に「やらない」という判断を下すこともあります。

つい最近も、Firefox用アドオン開発における自動テストを支援するテスティングフレームワーク「UxU」において、「Firefox 20以降ではE4Xを使ったテストケースをサポートしないことにする」という設計判断を下しました。これを例にとって、このエントリでは具体的な設計判断のプロセスを解説します。

発端

UxUには、テストケースの記述を容易にするためのユーティリティが多数実装されており、その中には、文字列として与えられたCSVを解釈したり加工したりするといった機能も含まれています*1。これらの機能を使う際には、長い文字列をテストケース中に埋め込む形で記述する方法として、E4XのCDATAマーク区間をヒアドキュメント記法の代わりとして使用することを想定していました。

JavaScriptには現在の所、複数行にわたるテキストを素直に文字列リテラルとしてソースコード中に記述する方法がありません。この問題を解決するためにE4Xを使うことができます。E4Xは本来はJavaScript中にXMLの要素をリテラルとして記述するためのものですが、CDATAマーク区間の記法を利用すると複数行にわたるテキストを比較的素直にリテラルとして記述できます。そのため、以下のようにヒアドキュメント代わりに利用するという用法が、一部の開発者の間で知られていました。

1
2
3
4
5
var longText = <><![CDATA[
      aaa bbb ccc
      ddd eee fff
      ggg hhh iii
    ]]></>.toString();

この用法は、配布物を1つのJavaScriptのファイルにまとめる必要があるユーザースクリプトなどにおいても重宝されているようです。

ところが、2013年にリリースされる予定のFirefox 20開発版において、E4Xを既定の状態で無効化するという決定が下りました(参照:788290 - Turn javascript.options.xml.chrome off by default)。このままFirefox 20がリリースされると仮定すると、UxU用に書かれたテストケースのうちE4Xを利用したテストケースはFirefox 20上では動作しません。

そこで、これを受けて、E4Xを使用して書かれたテストケースについてUxU側で何らかの対策を取ることができないかを検討することにしました。

テスティングフレームワークとして重視したい点

UxU用に書かれたテストケースを「UxUというアプリケーション専用のデータファイル」と捉えた場合、UxUが取るべき姿勢は「可能な限り互換性を保つ」「過去に作成されたデータファイルも、新しいバージョンで問題なく利用できるようにする」ということになります。この点について、疑問を差し挟む余地はないでしょう。

その方針に沿って考えると、今回のFirefoxの仕様変更に対して取り得るUxU側でのアプローチは、以下のようなものがあります。

  1. Firefox(Thunderbird)のE4X機能を自動的に有効化するようにする。
  2. E4Xを自前で実装する。
  3. テストケースの読み込み時に、E4Xをヒアドキュメント代わりに使用している箇所を検出して、文字列リテラルに自動的に変換する。

それぞれのアプローチについて、メリットとデメリットを考えてみます。

Firefoxの隠し設定を変更するというアプローチの検討

前述のbugおよびコミットされたパッチを見るとわかりますが、今回の変更は、E4Xの有効無効を切り替える設定を、既定の状態でオフにしておくというだけのものです。この設定は隠し設定ではありますが、Firefox上で動作するアドオンから見た場合、通常の設定項目のひとつと何ら変わりありません。よって、UxU自身が自動的にこの隠し設定を変更して、E4Xを再度有効化するということは普通にできてしまいます。

このアプローチのメリットは、単にFirefox自体の設定を変更するだけなのでUxU側の負担が非常に小さく済む、という点です。

それに対して、この方法のデメリットは最低でも2つあります。1つは、テスト実行時の環境が「テスト対象のコードが実際のユーザーの環境」からかけ離れた環境になってしまうことです。そもそもUxUがFirefox上で動作するアドオンとして開発されているのは、実際の環境になるべく近い環境でテストを走らせたいという理由からです。テスト実行時に環境そのものを変更してしまうと、「実際の環境でどんな問題が起こりうるか」ということを確かめられなくなってしまいます。

もう1つのデメリットは、この方法が明日にでも利用できなくなってしまうリスクがあるという点です。今回はE4Xの有効無効を切り替える設定の初期値が変わっただけでしたが、E4Xのパーサー自体が削除されてしまった場合、この選択肢はとれなくなります。

E4Xを自前で実装するというアプローチの検討

次に、E4XそのものをUxU側で実装する場合を検討します。

現在、E4Xに対応したJavaScriptの処理系は、C++製のSpiderMonkeyとJava製のRhinoがあり、どちらもオープンソースのプロダクトとして公開されています。よって、極端なことを言えば、これらのJavaScript処理系を丸ごと抱え込んでしまうということも技術的には可能です。

ただ、抱え込むコードの量が増えるという事でもあるため、メンテナンスコストは明らかに増大します。Rhinoのソースコードは全体で20MB、SpiderMonkeyのソースコードは40MBにも及ぶ巨大なプロダクトですし、それらを組み込んで動作させるためのコードも相当な規模になることが予想されます。UxUはクリアコード内製の製品ですが、開発に大きなコストをかける余裕は残念ながらありません*2。開発コストの増大は、プロジェクトそのものの存続を危うくしてしまいます。

また、この場合、必然的に、テスト対象のコードも「UxUが動作しているFirefoxのJavaScript実行エンジン」ではなく「テスト環境用にUxUが内蔵しているJavaScript実行エンジン」で走ることになります。これでは、テスト環境と実環境が大きくかけ離れてしまうという問題が残ってしまいます。

E4X(のCDATAマーク区間)の箇所を文字列リテラルに自動的に変換するというアプローチの検討

E4X全体に対応することは無理でも、E4Xをヒアドキュメント代わりに使っている箇所についてだけ対応することはできないか、という考え方もあります。100%完全にあらゆるケースに対応することは難しくとも、最小のコストで全体の8割程度の場合に対応できるようなやり方があるのであれば、それは有力な選択肢になり得ます。

このアプローチで鍵となるのは、E4Xがヒアドキュメントとして使われている箇所をどのようにして見つけるかです。その方法が十分に簡単なのであれば、この方法は検討に値すると言えます。

この方向でまず思いつくのは、スクリプト全体を文字列として扱った上で、E4Xの部分を正規表現で探して一括置換するという方法です。しかし、この方法には以下のようなスクリプトで「誤爆」が発生してしまうという問題があります。

1
2
var cdataLikeMatcher = /<![CDATA[]/;
if (array[object['property']]>10) { ... }

この問題を回避するためには、「<![CDATA[」という文字列がどのような文脈で登場しているのかを調べて、地の文として登場している場面だけを検出する必要があります。ですが、これは思ったほど簡単な事ではなく、JavaScriptにマクロ記法を導入するライブラリであるsweet.jsで採用されている字句解析レベルでの効率のよい手法を使ったとしても、それなりの規模のコードが必要となります。

「100%完全にあらゆるケースに対応することは難しくとも……」という観点でいえば、そこまでの事をせずとも、単純な文字列置換でできる場合についてはそれで動けばよしとして、動かない場合はE4Xの使用を諦めてスクリプトを書き直してもらう、というやり方もあるように思えるかもしれません。ですが、その場合に生じる「うまく動かないケース」には、E4Xをヒアドキュメント代わりに使っているケースだけではなく、先の例のような「それ自体は何の変哲もないスクリプトだが、たまたまE4Xに似ている文字列が登場する」ケースも含まれ得ます。本来使えないはずのE4XがE4Xとして使えないのは許容できるとしても、本来であれば何の問題も起こらないはずのスクリプトまでもが「E4X対策の影響」で動作しなくなってしまうのでは、本末転倒もいいところです。

前提を見直す

ここまでいくつかのアプローチを検討してきましたが、残念ながらどのアプローチにも問題があり、選ぶに選べない結果となってしまいました。

このように行き詰まってしまった時は、行き詰まりの原因になっている前提を疑ってみることも必要です。いくつかの選択肢を前にして、必ずしもその中からどれかを選ばなくてはならないということはありません。ある観点で見た時に解決策が見えなくても、視野を広げて別の観点から問題を捉え直すことで、解決策が見えてきたり、あるいは、問題が問題ではなくなってくることがあります。ここでは、「UxUでこの問題に対策を行わないといけない」という前提自体を疑ってみます。

そもそも今回の件の発端を考えると、問題提起の仕方は「E4Xをヒアドキュメント代わりに使っている部分がFirefox 20以降で動かなくなる。どうしよう。」というものでした。「E4Xの仕様がサポートされない」「E4Xで書かれた部分が動かない」という点は元々は問題視していなかったのです。

そして、ここでもう1つ大事なのが、ヒアドキュメントの記法があるか無いか・使えるか使えないかというのは、UxUに求められる機能というよりも、JavaScript一般で求められる機能であるという事です。

設計上の判断材料の1つとして、プロジェクトの責任範囲は常に頭に入れておく必要があります。いくら利便性が高まるといっても、本来の責任範囲を逸脱した部分に手を出し始めてしまうと、そのうち収拾がつかなくなってしまいます。今回はUxUのテストケースでのニーズが発端ではありましたが、「FirefoxやThunderbirdのアドオンの自動テストを支援する」というUxUプロジェクト本来の目的を考えると、これはいささか外れたトピックと言わざるを得ません。「Firefoxで動作するスクリプトはテストケース中でも全部利用できること」「UxUが提供するユーティリティやアサーションが正常に機能すること」といった事柄は本来の目的から演繹できる責任範囲ですが、「Firefoxが対応しなくなったJavaScriptの文法上の拡張仕様に対応すること」を演繹することは難しく、UxUの責任の範囲外にあると言えます。

結論

以上を踏まえて、最終的に、UxUプロジェクトはE4Xの事実上の廃止に対しては「特に何もしない」という判断を下すことにしました*3

この結論に至るまでに、以下の要素が判断基準として登場しました。

  • 手間(コスト)はどれだけ必要か。
  • プロジェクト本来の目的を疎外しないか。
  • どれだけの場合に対応できるか。(100%すべての場合に対応できるのか、一部のエッジケースには対応できないのか。)
  • 追加・変更が必要なコードの量はどの程度か*4
  • プロジェクトの持続性にどれだけ影響するか。
  • プロジェクトの責任範囲に含まれるか。

ある点を突き詰めると別の点で問題が増大するという風に、判断基準同士の間にトレードオフの関係がある場合、すべての基準を満足させる選択肢というものは無いことになります。その時に重要になるのが、判断基準同士の中での優先度です。そのプロジェクトが何を大事にしているのか・何を大事にしていくのかという軸がはっきりしていればいるほど、判断のぶれは小さくなりますし、素早い判断が可能になります。

また、目先の問題や、最初のよく調べていない時点で仮に設定した目標に囚われてしまうと、そのまま泥沼に突っ込んで戻って来られなくなってしまうことがあります。今回の事例で言えば、「E4Xの仕様を完全に満たさないといけない」という意識に囚われたばかりに、字句解析処理の実装に着手したものの、いつまで経っても完成に辿り着けず、そのうちプロジェクト自体が頓挫してしまう、という「最悪のケース」に陥ってしまう可能性も十分にありました。

自分が今進もうとしている道の先にあるのが底なし沼なのかどうなのかを早めに見極めて、これは駄目だとなったら深手を負う前に引き返すことが大切です。そのためにも、何を大事にするのかという判断基準が必要になります。「そこは大事にしなくてもよい」ということが分かっていれば、引き返す判断を早めに下しやすいですし、埋没費用の発生もなるべく小さく抑えられます。

最良の選択を最初の時点ですぐに行い、それに向かって無駄なく一直線で進むのが、理想的といえば理想的です。しかし、最初の時点ですべてを知り尽くせていない事はままあります。今回も、調べ始めてみるまで「ソースコードの中で、どこがE4XのCDATAマーク区間なのかを判別する」ということがそこまでコストの高い事であるとは分かっていませんでした。最初にすべての方針を決めてまっすぐ邁進することに比べると、このような進め方は一見、無駄が多く不格好かもしれません。しかし、一度に投入できるリソースに厳しい制限がある場合には、その時々の目標設定を随時見直してスピーディーに方針を転換していく方が、リスクやコストを平滑化できるのではないでしょうか。

もちろんその時には、ぶれない大目的が存在していることが前提にあります。大目的無しに方針転換を繰り返すのは、ただの迷走です。目標は、目的に向かって進むための途中の道しるべに過ぎません。今回の例で言えば、「アドオンのコードを実際の環境に近い環境で簡単にテストできるテスティングフレームワークを提供すること」が目的で、「E4XのCDATAマーク区間を使ったヒアドキュメント的な機能を利用できること」は(仮の)目標です。目的のためには目標は動かしてもいいものです。その逆に、目標のために目的が犠牲になって(目的がぶれて)はいけません。

皆さんもプロジェクトを運営される際には、目的をはっきりと持ち、それに則った設計判断を下すよう心がけましょう。

付録:ユーザ側での、あるいは別プロジェクトとしての解決の方向性

なお、UxUプロジェクトとしては「責任の範囲外なので、この件についてはなにもしない」という態度を取るとしても、実際にUxUを利用してテストケースを書くユーザー開発者としては、何らかの対策がないと困ります。そこで、UxUとは別のレイヤーでこの問題を解決する方法についても検討しました。

ヒアドキュメント風の機能を実現する別のアプローチとして、文法を拡張するのではなく、JavaScriptの文法の範囲内で実現するという考え方があります。その実例が、cho45さんがNode.js用に開発されているnode-hereというライブラリです。

このライブラリは「ライブラリが提供するhere()という関数が呼び出された時のスタックトレースを参照して、ソース中の何文字目にあたる箇所で関数が呼ばれたのかを特定し、ソースを文字列として読み込んだ上でその箇所に書かれたコメントの内容を抽出して、それを文字列として返す」という処理を行うものです。スクリプトが実行される時点では純粋に単なる関数呼び出しでしかないため、これのせいで普通のスクリプトが動作しなくなるといった副作用もありません。Node.js(V8)用に開発されていますが、動作だけを見ればFirefox上でも同じようなことができるはずです*5

調査の結果、このアプローチにも限界があることは分かっています*6が、実用上はほぼ問題ないと考えられます*7

今回はUxUプロジェクトとしての対応は見送りましたが、このような機能が十分に小さなライブラリとして実装されるならば、標準添付のライブラリの1つとして含めるという選択もあり得るかもしれません*8

何かの機能を廃止したり、今まではできたことをできなくしたりすると、ユーザーからは必ず反発があります。有効な代替案がないままにただ「できなくなりました」とだけ言い放ってしまうと、ユーザーの不興を買ってしまい、ユーザー離れを引き起こしたり、古いバージョンを使い続けられてしまったりする事もあります*9。「なぜできなくなったのか」という事を説明したとしても、この点は変わりません*10。ですのでこのような場合には、ユーザーの負担を減らすためにも、何らかの形で有効な代替案を用意しておくか、案内をしておくのが望ましいでしょう。

*1 主に、データ駆動テストで利用することを想定しています。

*2 UxUの開発そのものは会社の収入に直結しない上に、その間、収入に繋がる他の業務もストップしてしまうため。

*3 もちろん、UxU自身のコードでE4Xを使っている箇所があれば、そのままではUxU自体が動作しなくなってしまうため、その点の修正は厭わない方針です。

*4 変更の量は少なくても、その後に致命的な悪影響を与える変更、というものもあるので、量だけが重要というわけではない。

*5 実際に、再起動不要なアドオンの開発用の簡易フレームワークRestartlessの一部としてhere.jsを実装しています。

*6 Node.jsではスタックトレースから行内のカラム位置を取得できるため、正確に関数呼び出しが行われた位置を把握できるのですが、Firefoxではスタックトレースから行番号までしか特定できないため、同じ行に複数のhere()が存在していた場合に、どのhere()が呼ばれたのかがわかりません。

*7 元々の目的が「複数行にわたる文字列をすっきり記述したい」という事であると考えれば、1行に複数のhere()が登場するようなケースは相当イレギュラーであると言えるため。最小の実装でほとんどの場合に対応できるのであればよしとする、という考え方に基づけば、この点は目をつぶることができる。

*8 例えば、非同期処理のテストを行う際の利便性向上のため、UxUにはJSDeferredを標準添付している。

*9 実際に、愛用しているアドオンが対応しているという理由からセキュリティ上の脆弱性がある古いバージョンのFirefoxを使い続けている人もいる。このような事態が発生すると、結果的にユーザーを危険に晒してしまうことになるため、非常によろしくない。

*10 背景事情など、ユーザーにとってはどうでもいいことなので。

つづき: 2014-01-09
タグ: Mozilla | UxU
2013-01-16

Firefox/Thunderbird用アドオン開発者向けテスティングフレームワーク UxU 1.0.0をリリースしました

長らく更新がない状態が続いてしまっておりましたが、Firefox/Thunderbird用アドオンの開発者向けテスティングフレームワークUxU(UnitTest.XUL)の新バージョンとなるバージョン1.0.0を、本日付けでリリースしました。動作対象は、現在Mozillaによって公式にサポートが継続されているFirefox 10 ESR、Thunderbird 10 ESR、および最新のリリース版までです。Firefox 3.6、Thunderbird 3.1などの旧バージョンでの動作は保証されていませんので、ご注意下さい。

テストの並列実行

バージョン1.0.0では影響の大きな変更点として、複数のテストの並列実行に関する動作が大幅に改善され、実用的な機能になりました。

設定画面から最大の並列実行数を変更するか、コマンドライン引数を通じて並列実行数を明示的に指定することで、これまですべてシーケンシャルに実行されていたテストが、テストケース(ファイル)単位で並列に実行されるようになります。

並列実行が有効な場面

JavaScriptを使ったテストでは、Webページの読み込みなど、同期的には結果を得られない・処理待ちが必要となる場面が多くあります。UxUでは、ユーティリティメソッドなどを通じて簡単に処理待ちを行える事が特長の1つとなっています。

しかしながら、処理待ちが発生するという事は、必然的に、全体のテスト実行時間が長くなってしまう事にも繋がります。

複数のテストを並列実行すると、1つのテストで処理待ちしている間に、別のテストの処理を進める事ができます。処理待ちしている時間を無駄にせずに済むようになるため、処理待ちが多い場合においては、全体的なテスト実行時間の短縮が期待できます。

並列実行が有効でない場面

UxUは、プロファイルを指定して別プロセスを起動する場合を除き、すべてのテストが同一プロセスの同一スレッド上で動作する設計となっています。そのため、単純なforループやwhileループなど、同期的な処理により時間がかかってしまっている場面については、他のテストを並列実行する事ができません。

このような理由でテストの実行時間が長くなっているケースについては、残念ながら、並列実行では実行時間の短縮は望めません。

並列実行できない場面

テストの設計によっては、他のテストと並列に実行するとランダムに失敗するようになってしまう場合があります*1。並列実行機能は、個々のテスト同士が衝突しないようになっている事を確認した上で利用して下さい。

なお、テストケース中で「var parallel = false;」と明示的に指定する事により、そのテストケースを並列実行の対象外にする事ができます。並列実行の対象外になっているテストケースは、同時に2つ以上実行される事はありません*2。並列実行すると失敗するテストについてはこの方法で問題を回避し、できる所から並列実行の恩恵を得るという事もできます。

まとめ

UxU バージョン1.0.0で実用的な機能となった複数テストの並列実行機能について、有効な場面と有効でない場面、問題が起こる場面などの情報を簡単にご紹介しました。テスト実行時間の長さに悩まされていた方は、是非一度お試し下さい。

*1 例えば、単一のテスト用フレームを2つのテストで同時に使用してしまっている場合や、複数のテストでそれぞれウィンドウを開いていて「最前面にあるウィンドウ」を取り合ってしまう場合など。

*2 並列実行の対象になっているテストが他にある場合は、それとは並行して実行されます。

タグ: Mozilla | テスト | UxU
2012-07-24

処理待ちをより簡単に行えるようになったUxU 0.7.6をリリースしました

2010年1月29日付で、テスティングフレームワークUxUのバージョン0.7.6をリリースしました。

新機能のutils.wait()について

今回のアップデートでの目玉となる新機能は、非同期な機能のテストをより簡単に記述できるようにするヘルパーメソッドであるutils.wait()です。これは、以下のように利用します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function testSendRequest() {
  myFeature.sendRequest();
  utils.wait(1000); // 1000ミリ秒=1秒待つ
  assert.equals('OK', myFeature.response);
}

function testLoad() {
  var loaded = { value : false };
  content.addEventListener('load', function() {
    content.removeEventListener('load', arguments.callee, false);
    loaded.valeu = true;
  }, false);
  myFeature.load();
  utils.wait(loaded); // valueがtrueになるまで待つ
  assert.equals('OK', content.document.body.textContent);
}

また、utils.wait()は関数の中でも利用できます。

1
2
3
4
5
6
7
8
9
10
function testSendRequest() {
  function assertSend(aExpected, aURI) {
    myFeature.sendRequest(aURI);
    utils.wait(1000);
    assert.equals(aExpected, myFeature.response);
  }
  assertSend('OK', '...');
  assertSend('NG', '...');
  assertSend('?', '...');
}

utils.wait()が受け取れる値は、これまでの処理待ち機能で yieldに渡されていた値と同じです。詳しくは処理待ち機能の利用方法をご覧下さい。大抵の場合、yield Do(...);と書かれていた箇所は、utils.wait(...);へ書き換えることができます。

ただし、このヘルパーメソッドはFirefox 3以降やThunderbird 3以降など、Gecko 1.9系の環境でしか利用できません。Thunderbird 2などのGecko 1.8系の環境ではエラーとなりますのでご注意下さい。(それらの環境でもテストの中で処理待ちを行いたい場合は、従来通りyieldを使用して下さい。)

これまでの処理待ち機能の特徴と欠点

これまでUxUでは、「機能を実行した後、N秒間待ってから、機能が期待通りに働いたかどうかを検証する」「初期化処理で、ページの読み込みの完了を待ってから次に進む」といった処理待ちを実現する際は、JavaScript 1.7以降で導入されたyieldを使う仕様となっていました。

yieldを含む関数はジェネレータとなり、関数の戻り値をイテレータとして利用できるようになります。この時ジェネレータの内側から見ると、「yieldが出現する度に処理が一時停止する」という風に考えることができます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function gen() {
  alert('step1');
  yield 'step1 done';
  alert('step2');
  yield 'step2 done';
  alert('step3');
}
var iterator = gen(); // この時点ではまだ関数の内容は評価されない
var state;
state = iterator.next(); // 'step1' が表示される
alert(state);            // 'step1 done' が表示される
state = iterator.next(); // 'step2' が表示される
alert(state);            // 'step2 done' が表示される
try {
  state = iterator.next(); // alert('step3'); が実行される
} catch(e if e instanceof StopIteration) {
  // 次のyieldが見つからないので、StopIteration例外が投げられる
}

UxUに従来からある処理待ち機能は、この考え方を推し進めて作られています。テスト関数の中にyieldがある場合(つまり、関数の戻り値がイテレータとなる場合)は、フレームワーク側で自動的にイテレーションを行い、yieldに渡された値をその都度受け取って、次にイテレーションを行うまでの待ち条件として利用しています。例えば、数値が渡された場合はその値の分の時間だけ待った後で次にイテレーションを行う、といった具合です。

このやり方の欠点は、yieldを含む関数から任意の戻り値を返すことができないという点です。

1
2
3
4
5
6
7
8
9
10
11
function gen() {
  alert('step1');
  yield 'step1 done';
  alert('step2');
  return 'complete';
}
try {
  var iterator = gen();
} catch(e) {
  alert(e); // TypeError: generator function gen returns a value
}

returnを書くと、関数の実行時にエラーになってしまいます。どうしても何らかの値を取り出したい場合は、値を取り出すためのスロットとなるオブジェクトを引数として渡すなどの工夫が必要になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function gen(aResult) {
  alert('step1');
  yield 'step1 done';
  alert('step2');
  aResult.value = 'complete';
}
var result = {};
var iterator = gen(result);
var state;
state = iterator.next(); // 'step1' が表示される
alert(state);            // 'step1 done' が表示される
try {
  state = iterator.next(); // 'step2' が表示される
} catch(e if e instanceof StopIteration) {
  alert(result.value); // 'complete' が表示される
}

また、ジェネレータは実行してもその段階では関数の内容が評価されないという点にも注意が必要です。例えば以下のようなテストは、期待通りには動いてくれません。

1
2
3
4
5
6
7
8
9
10
function testSendRequest() {
  function assertSend(aExpected, aURI) {
    myFeature.sendRequest(aURI);
    yield 1000;
    assert.equals(aExpected, myFeature.response);
  }
  assertSend('OK', '...');
  assertSend('NG', '...');
  assertSend('?', '...');
}

この例では、assertSend()を実行したことで戻り値としてイテレータが返されているものの、そのイテレータに対するイテレーションが一切行われていないため、リクエストも行われなければアサーションも行われないということになってしまっています。これは以下のように、返されたイテレータをそのままフレームワークに引き渡して、フレームワーク側でイテレーションを行わせる必要があります。

1
2
3
4
5
6
7
8
9
10
function testSendRequest() {
  function assertSend(aExpected, aURI) {
    myFeature.sendRequest(aURI);
    yield 1000;
    assert.equals(aExpected, myFeature.response);
  }
  yield assertSend('OK', '...');
  yield assertSend('NG', '...');
  yield assertSend('?', '...');
}

また、このままではジェネレータの中で発生した例外のスタックトレースを辿れないという問題もあります。スタックを繋げるためには、ヘルパーメソッドのDo()を使ってyield Do( assertSend('OK', '...') )のように書かなければなりません。

utils.wait()を使う場合、これらのことを考慮する必要はありません。冒頭のサンプルコードのように、素直に書けば素直に動作してくれます。

スレッド機能を使った処理待ち

utils.wait()がどのように実装されているかについても解説しておきます。

このメソッドの内部では、Gecko 1.9から実装されたスレッド関連の機能を利用しています。

1
2
3
4
window.setTimeout(function() {
  alert('before');
}, 0);
alert('after');

JavaScriptは基本的にシングルスレッドで動作するため、このようにタイマーを設定すると、その処理はキューに溜められた状態となります。その上で、メインの処理が最後まで終わった後でやっとキューの内容が処理され始めるため、この例であれば「after」「before」の順でメッセージが表示されることになります。

1
2
3
4
5
6
7
8
9
10
11
12
var finished = false;
window.setTimeout(function() {
  alert('before');
  finished = true;
}, 0);
var thread = Cc['@mozilla.org/thread-manager;1']
              .getService()
              .mainThread;
while (!finished) {
  thread.processNextEvent(true);
}
alert('after');

Gecko 1.9以降のスレッド機能を使うと、メインの処理を一旦中断して先にキューに溜められた処理の方を実行し、その後改めてメインの処理に戻るということができます。実際に、こちらの例では「before」「after」の順でメッセージが表示されます。UxU 0.7.6ではこれを応用して、任意の条件が満たされるまでthread.processNextEvent(true);でメインの処理を停止し続けることによって、処理待ちを実現しています。

なお、HTML5にもWeb Workersというスレッド関係の機能がありますが、こちらは別スレッドでスクリプトを動作させる機能しか持っていないため、上記のようなことは残念ながらできません。

まとめ

UxU 0.7.6からは、utils.wait()を使ってより簡単に処理待ちを行えるようになりました。Firefox 3以降やThunderbird 3以降専用にアドオンを開発する際には、是非利用してみて下さい。

つづき: 2010-12-29
タグ: UxU | テスト | Mozilla
2010-01-29

UxUを用いたデータ駆動テストの記述

2009年10月30日付で、テスティングフレームワークUxUのバージョン0.7.5をリリースしました。

UxUはこれまで「Firefoxアドオン開発用テスティングフレームワーク」と銘打っていましたが、Thunderbird用アドオンの開発にも利用されていることと、バージョン0.7.0以降からXULRunnerベースのアプリケーション一般に対してインストール可能なようになったことから、現在のプロジェクトページ上では「Firefox/Thunderbird用アドオン・XULRunnerアプリケーション開発用テスティングフレームワーク」と表記しています。

バージョン0.7.0以降で、UxUはデータ駆動テストの記述に対応しました。今回はUxUでのデータ駆動テストの記述方法の解説を通じて、データ駆動テストの利便性についてご紹介したいと思います。

このエントリ内の目次:

  1. データ駆動テストとは?
    1. べた書きしたテスト
    2. 関数を使ったテスト
    3. ループを使ったテスト
    4. データ駆動テスト
  2. UxUでのデータ駆動テストの書き方
  3. データ駆動テスト用の便利な機能

データ駆動テストとは?

データ駆動テストとは、簡単に言えば、「テストのロジックとデータを分離した自動テスト」の事です。データ駆動テストの利点を理解していただくために、データ駆動テストではないテストの例もいくつか挙げながら、それぞれの利点と欠点を見ていきましょう。

べた書きしたテスト

以下は、XUL/Migemoという「ローマ字入力で普通の日本語の検索を行う」アドオンの中の、半角英数字によるローマ字入力をひらがなに変換するモジュールのテストの一部です。

1
2
3
4
5
6
7
8
9
10
11
12
13
function test_roman2kana() {
  assert.equals('あいうえお',      transform.roman2kana('aiueo'));
  assert.equals('aiueo',      transform.roman2kana('aiueo'));
  assert.equals('がぎぐげご',      transform.roman2kana('gagigugego'));
  assert.equals('にほんご',        transform.roman2kana('nihongo'));
  assert.equals('ぽーと',          transform.roman2kana('po-to'));
  assert.equals('きゃっきゃ',      transform.roman2kana('kyakkya'));
  assert.equals('うっうー',        transform.roman2kana('uwwu-'));
  assert.equals('\\(\\)\\[\\]\\|', transform.roman2kana('\\(\\)\\[\\]\\|'));
  assert.equals('\\(\\[',          transform.roman2kana('\\(\\['));
  assert.equals('\\)\\]',          transform.roman2kana('\\)\\]'));
  assert.equals('\\|',             transform.roman2kana('\\|'));
}

このモジュールのroman2kana()メソッドは、半角英数字のローマ字入力をひらがなに変換し、それ以外の入力はそのまま返すという仕様になっています。この仕様通りに動作するかどうかを検証するため、ここでは11種類の引数を渡し、戻り値をassert.equals()で検証しています。検証しないといけないパターンが増えた時には、行をコピーして引数と期待値の部分を書き換えることになります。

パッと見て分かるかと思いますが、このテスト用コードには以下のような問題があります。

  • 同じコードの繰り返しが多く冗長である
  • テスト対象のメソッドを呼び出す記述が直接書かれているため、メソッド名や引数の取り方が変わった時などには、11行すべてを書き換える必要がある

「テストのロジック」と「テストしなければいけないデータ」が一緒に記述されているため、メンテナンス性が低いコードになってしまっていると言えます。

関数を使ったテスト

このようなテストのメンテナンス性を高めるための方法の1つとしては、アサーションを行う部分を関数としてまとめておくというやり方が考えられます。例えば以下の要領です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function test_roman2kana() {
  function oneTest(aExpected, aInput) {
    assert.equals(aExpected,
                  transform.roman2kana(aInput));
  }
  oneTest('あいうえお',      'aiueo');
  oneTest('aiueo',      'aiueo');
  oneTest('がぎぐげご',      'gagigugego');
  oneTest('にほんご',        'nihongo');
  oneTest('ぽーと',          'po-to');
  oneTest('きゃっきゃ',      'kyakkya');
  oneTest('うっうー',        'uwwu-');
  oneTest('\\(\\)\\[\\]\\|', '\\(\\)\\[\\]\\|');
  oneTest('\\(\\[',          '\\(\\[');
  oneTest('\\)\\]',          '\\)\\]');
  oneTest('\\|',             '\\|');
}

コードの冗長さが減りました。また、メソッド名や引数の取り方が変わった時も、変更が必要な箇所は1箇所だけになりました。

これでも悪くはないのですが、実際にテストを繰り返し走らせていると、以下のような問題が浮き彫りになってきます。

  • テストする入力パターンのどれか1つでアサーションに失敗したら、そこから後の記述がスキップされてしまう

例えば以下のようなシナリオが考えられます。

  1. テストを実行した。
  2. 2番目のパターンでfailした。
  3. 2番目のパターンでfailする原因となっていたバグを直した。
  4. 再度テストを実行した。
  5. 5番目のパターンでfailした。
  6. 5番目のパターンでfailする原因となっていたバグを直した。
  7. 再度テストを実行した。
  8. 6番目のパターンでfailした。
  9. …(以下続く)

「3の段階で行った修正で5や8のバグが発生した」という可能性もありますが、最初から2や5や8のバグがあったのであれば、まとめて直せていたかもしれません。これでは、バグを直しても直してもきりがないという、モグラ叩きのような感覚に陥ってしまいます。

ループを使ったテスト

メンテナンス性を高める別の手法として、アサーションに使う期待値とメソッドに渡す引数だけを配列で別途定義しておくというやり方も考えられます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function test_roman2kana() {
  var patterns = [
        ['あいうえお',      'aiueo'],
        ['aiueo',      'aiueo'],
        ['がぎぐげご',      'gagigugego'],
        ['にほんご',        'nihongo'],
        ['ぽーと',          'po-to'],
        ['きゃっきゃ',      'kyakkya'],
        ['うっうー',        'uwwu-'],
        ['\\(\\)\\[\\]\\|', '\\(\\)\\[\\]\\|'],
        ['\\(\\[',          '\\(\\['],
        ['\\)\\]',          '\\)\\]'],
        ['\\|',             '\\|']
      ];
  for each (var pattern in patterns) {
    assert.equals(pattern[0],
                  transform.roman2kana(pattern[1]));
  }
}

これにもやはり問題があります。

  1. テストする入力パターンのどれか1つでアサーションに失敗したら、そこから後に記述された入力パターンに対するアサーションが行われない。(関数を使った書き方の場合と同じ問題)
  2. 配列の内容の順番を覚えておかないといけない
  3. failした時に、どの入力パターンでfailしたのかが分からない
  4. 1、2、3の問題を回避するための対処を毎回しなければならないのが面倒

1つ目の問題点については前述したとおりです。

2つ目の問題は、この例のように2次元配列を使った場合に現れます。上記の例では配列の0番目の要素が期待値、1番目の要素が入力となっていますが、これではどっちがどっちなのかを常に意識する必要があります。

3つ目は、ループに特有の問題です。UxUではテストにfailした時にスタックトレースが表示され、べた書きした場合や関数を使った書き方の場合であれば、スタックトレースを辿れば「どのパターンで失敗したのか」の情報に辿り着くことができます。しかし、ループを使っていると、スタックトレースの行き着く先はループの中になってしまうため、どのパターンに対して失敗したのかが一目では分からなくなってしまいます

この対策として、UxUのアサーションでは最後の引数として任意のメッセージを渡せるので、以下のようにして「どのパターンで失敗したのか」を表示させることは可能です。

1
2
3
assert.equals(pattern.expected,
              transform.roman2kana(pattern.input),
              pattern.input+'に対するテスト');

しかし、実際にたくさんテストを書くようになってくると、これが地味に面倒です。これが4つ目の問題点です。

べた書きした場合や関数を使った書き方の場合であれば、このような配慮なしに淡々とテストを書いていても、テスト実行時にはスタックトレースを辿ればデバッグに必要な情報を得られます。それなのに、ループに対してはこのような配慮をしなければいけないわけです。この面倒さによって、テストを新しく書いたり過去に書いたテストをメンテナンスしたりする意欲がじわじわと削がれてしまう、というのが一番の問題点だと言えます。

データ駆動テスト

UxU 0.7.0以降で導入されたデータ駆動テストの仕組みを使うと、上記のテストはこのように書くことができます。

1
2
3
4
5
test_roman2kana.parameters = utils.readParametersFromCSV('patterns.csv');
function test_roman2kana(aParameter) {
  assert.equals(aParameter.expected,
                transform.roman2kana(aParameter.input));
}

テスト用のコードにはロジックだけを書き、データは外部ファイルで定義します(後述しますが、テストケース内にデータを埋め込むこともできます)。データを定義しているファイルの形式はCSVです。

patterns.csvの内容
 inputexpected
半角英数aiueoあいうえお
全角英数aiueoaiueo
濁音のみgagigugegoがぎぐげご
濁音混じりnihongoにほんご
音引きpo-toぽーと
拗音kyakkyaきゃっきゃ
撥音uwwu-うっうー
paren\\(\\)\\[\\]\\|\\(\\)\\[\\]\\|
parenOpen\\(\\[\\(\\[
parenClose\\)\\]\\)\\]
pipe\\|\\|

この時、UxUは「test_roman2kana」という1つのテストではなく、「test_roman2kana (半角英数)」「test_roman2kana (全角英数)」……という名前の11個のテストを実行するようになります。

  • コードの繰り返しが無く、簡潔に書ける
  • すべてが独立したテストとして扱われるので、入力パターンのどれか1つでアサーションに失敗しても、他のテストはスキップされない
  • (CSVの場合)カラム名が引数として渡ってくるハッシュのキーとなるので、引数の順番を覚えておかなくていい
  • failした時は、実行されたテストの名前の中に実行時の入力パターンの名前が含まれるので、どのパターンで失敗したのかがすぐ分かる
  • これらをフレームワークの機能として提供しているので、テストを記述する時に面倒なことを考えなくてもよい

このように、データ駆動テストには多くのメリットがあります。単純な入出力のパターンを数多く検証しなければいけない場面で、データ駆動テストは威力を発揮します。

UxUでのデータ駆動テストの書き方

UxUでは、テスト関数のparametersプロパティに配列またはハッシュを代入すると、そのテストをデータ駆動テストとして実行するようになります。この時テスト関数には引数として、parametersプロパティの配列またはハッシュの要素が1つずつ渡されます。

以下は、配列を指定した場合の例です。

1
2
3
4
5
6
7
8
9
10
11
12
13
test_someFunc.parameters = [
  { expected : '29',   input : 'niku' },
  { expected : '2929', input : 'nikuniku' },
  { expected : '029',  input : 'oniku' }
];
function test_someFunc(aParameter) {
  /*
    1回目: aParameter = { expected : '29',   input : 'niku' }
    2回目: aParameter = { expected : '2929', input : 'nikuniku' }
    3回目: aParameter = { expected : '029',  input : 'oniku' }
  */
  ...
}

ハッシュを指定した場合は、以下のようになります。ハッシュのキーはテスト関数には渡されず、結果を表示する時のテスト名として表示されます。

1
2
3
4
5
6
7
8
9
10
11
12
13
test_someFunc.parameters = {
  simgle: { expected : '29',   input : 'niku' },
  double: { expected : '2929', input : 'nikuniku' },
  o:      { expected : '029',  input : 'oniku' }
};
function test_someFunc(aParameter) {
  /*
    1回目: aParameter = { expected : '29',   input : 'niku' }
    2回目: aParameter = { expected : '2929', input : 'nikuniku' }
    3回目: aParameter = { expected : '029',  input : 'oniku' }
  */
  ...
}

データ駆動テスト用の便利な機能

データ駆動テストのサポートに併せて、いくつかの新しいヘルパーメソッドが追加されています。これらを使うことで、データ駆動テストをより簡単に作成・メンテナンスすることができます。

前述の例で使用しているutils.readParametersFromCSV()は、CSVファイルの内容を読み込み、最初の行のカラム名をキーとしたハッシュとして返します。例えば前述の例のCSVは、以下のようなハッシュとして解釈されます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// test_roman2kana.parameters = utils.readParametersFromCSV('patterns.csv');
// これは、以下のように書くのと同じ
test_roman2kana.parameters = {
  '半角英数':   { input: 'aiueo',           expected: 'あいうえお' },
  '全角英数':   { input: 'aiueo',      expected: 'aiueo' },
  '濁音のみ':   { input: 'gagigugego',      expected: 'がぎぐげご' },
  '濁音混じり': { input: 'nihongo',         expected: 'にほんご' },
  '音引き':     { input: 'po-to',           expected: 'ぽーと' },
  '拗音':       { input: 'kyakkya',         expected: 'きゃっきゃ' },
  '撥音':       { input: 'uwwu-',           expected: 'うっうー' },
  paren:        { input: '\\(\\)\\[\\]\\|', expected: '\\(\\)\\[\\]\\|' },
  parenOpen:    { input: '\\(\\[',          expected: '\\(\\[' },
  parenClose:   { input: '\\)\\]',          expected: '\\)\\]' },
  pipe:         { input: '\\|',             expected: '\\|' }
};

CSVファイルはRFC4180準拠の形式の読み込みに対応しています。カンマ区切りではなくタブ区切りのファイルを使用したい場合は、utils.readParametersFromTSV()を使用して下さい。どちらも、読み込みたいファイルのパス(相対パスも利用できます)を第1引数に、ファイルのエンコーディングを第2引数に指定します。エンコーディング指定を省略した場合はUTF-8として読み込みます。

以下のリンク先に、各メソッドの詳しい説明があります。

また、JSON形式で保存した外部ファイルを読み込むためのutils.readJSON()というメソッドもあります。

1
test_roman2kana.parameters = utils.readJSON('patterns.json');

こちらも、読み込みたいファイルのパス(相対パスも利用できます)を第1引数に、ファイルのエンコーディングを第2引数に指定します。エンコーディング指定を省略した場合はUTF-8として読み込みます。詳しい説明は以下のリンク先をご覧下さい。

まとめ

データ駆動テストの仕組みを利用すると、テストのロジックとデータを分離できるため、メンテナンス性が高まることが期待できます。様々なパターンの入力を受け付ける機能を開発する時は、データ駆動テストをぜひ一度試してみて下さい。

タグ: Mozilla | UxU | テスト
2009-10-30

問題解決につながるエラーメッセージ

プログラムを書いていると問題に遭遇します。問題に遭遇したときはエラーメッセージが問題解決の重要な情報になります。しかし、エラーメッセージがあるだけでは問題解決にはつながりません。問題解決に役立つエラーメッセージとそうでもないエラーメッセージがあります。

ここでは、Rubyでの例をまじえながら問題解決に有用なエラーメッセージを紹介します。ライブラリなど多くの人が使うようなプログラムを作成する場合は参考になるかもしれません。

問題解決への道

問題に遭遇してから問題を解決するまでには以下の順で作業をする必要があります。

  1. 問題の把握
  2. 問題の原因の調査
  3. 原因の解決方法の検討
  4. 解決方法の実装

役立つエラーメッセージがあると「1. 問題の把握」、「2. 問題の原因の調査」、「3. 原因の解決方法の検討」がはかどります。

問題の値を示す

エラーが発生すれば問題が起こっている事実は把握できます。次にすることは、どのような問題が起こっているかを調査することです。

String#gsubにはいくつかの使い方がありますが、その1つは以下のように正規表現と文字列を引数にする使い方です。

1
2
>> "abcde".gsub(/c/, "C")
=> "abCde"

もちろん、違うオブジェクトを渡すとエラーが発生します。

1
2
3
4
>> "abcde".gsub([:first], [:second])
TypeError: can't convert Array into String
        from (irb):2:in `gsub'
        from (irb):2

配列を文字列に変換できなかったといっています。しかし、ここでは引数に配列を2つ指定しています。このエラーメッセージでは「配列を文字列に変換できなかった」ことはわかりますが、「どの配列を文字列に変換できなかった」かはわかりません。

正規表現のリテラルでも、正規表現の構文が間違っている場合はエラーが発生します。

1
2
3
4
5
>> Regexp.new("(")
RegexpError: premature end of regular expression: /(/
        from (irb):3:in `initialize'
        from (irb):3:in `new'
        from (irb):3

この場合は「正規表現に問題がある」というだけではなく、「どの正規表現に問題がある」かも示しています。

このように、問題を起こしたオブジェクトの情報も示すことで「問題を把握」しやすくなります。エラーメッセージには、問題を起こしたオブジェクトの情報も含めるようにしましょう。

どう悪いのかを示す

問題が把握できたら、どうしてその問題が発生したのか、原因を調べます。多くの場合、エラーメッセージに問題の原因は書かれています。しかし、そうではない場合もあります。できるだけ、エラーメッセージには問題の原因も含めるようにしましょう。

Time.iso8601はISO 8601で定められた文字列のフォーマットをパースし、Timeオブジェクトにします。

1
2
3
4
>> require 'time'
=> true
>> Time.iso8601("2009-04-10T12:02:54+09:00")
=> Fri Apr 10 03:02:54 UTC 2009

不正なフォーマットの場合はエラーが発生します。

1
2
3
4
>> Time.iso8601("2009-04-10I12:02:54+09:00")
ArgumentError: invalid date: "2009-04-10I12:02:54+09:00"
        from /usr/lib/ruby/1.8/time.rb:376:in `iso8601'
        from (irb):6

この例では真ん中あたりの「T」が「I」になっているためフォーマットに適合していません。

もし、「『I』という不正な文字があります」というようなメッセージが入っていると、問題の原因を簡単に把握できるようになります。

エラーメッセージには大雑把な原因だけではなく、できるだけ詳しく原因を書くようにしましょう。

期待を示す

問題の原因がわかったら、その問題を解決する方法を検討します。期待している値がわかると、解決する方法を検討しやすくなります。

String#deleteは1つ以上の引数をとります。1つも引数を与えない場合はエラーが発生します。

1
2
3
4
5
6
>> "abcde".delete("a")
=> "bcde"
>> "abcde".delete
ArgumentError: wrong number of arguments
        from (irb):2:in `delete'
        from (irb):2

エラーメッセージを見ると「引数の数が違う」ということがわかります。これで「問題の原因」を把握することができます。

しかし、「問題の原因」はわかってもどうすればその問題を解決できるかはわかりません。引数の数を変えればよいということはわかりますが、いくつにすればよいかがわからないのです。

期待している値を示すと、問題を解決しやすくなります。

1
2
3
4
>> "abcde".gsub
ArgumentError: wrong number of arguments (0 for 2)
        from (irb):3:in `gsub'
        from (irb):3

このエラーメッセージからはString#gsubが2つの引数を期待していることがわかるので、解決案として「引数を2つ渡す」というアイディアが浮かびます。次にすることは「引数に何を2つ渡すか」を考えることです。

エラーメッセージに「期待していること」を含めると、解決案が浮かびやすくなります。できるだけ、期待していることも含めるようにしましょう。

まとめ

Rubyを例にして問題解決に役立つエラーメッセージについて紹介しました。

問題解決に役立つエラーメッセージの特長は、テストの実行結果にもあてはまります。クリアコードが開発に関わっているテスティングフレームワークではテストの実行結果にこだわっています。

  • Cutter: C言語用テスティングフレームワーク
  • UxU: Firefoxアドオン開発用テスティングフレームワーク

あなたが使っているテスティングフレームワークは問題解決に役立つような情報を提供していますか?

タグ: Ruby | Cutter | UxU
2009-04-10

UxUで外部テキストエディタを使う時のおすすめ設定

みなさん、テストしてますか?(挨拶)

UxUでは、テスト失敗時に表示されるスタックトレースからテキストエディタを起動することができます。この時、利用するテキストエディタがコマンドライン引数による行指定に対応していれば、エラーが発生した行を直接開いて編集できます。きちんと設定しておけば、テストを実行して、編集して、またテストして、といったサイクルで開発を進められるので非常に便利です。

以下に、有名なテキストエディタ向けの設定の例をいくつか挙げてみました。UxUの設定ダイアログの「MozUnitテストランナー」タブでエディタ起動用のコマンドとして入力してください。(エディタの実行ファイルのパスは必要に応じて読み替えてください)

秀丸エディタ
"C:\Program Files\Hidemaru\Hidemaru.exe" /j%L,%C "%F"
TeraPad
"C:\Program Files\TeraPad\TeraPad.exe" /j=%L "%F"
サクラエディタ
"C:\Program Files\sakura\sakura.exe" "%F" -X=%C -Y=%L
EmEditor
"C:\Program Files\EmEditor\EmEditor.exe" /l %L /cl %C "%F"
xyzzy
"C:\Program Files\xyzzy\xyzzycli.exe" -l "%F" -g %L -c %C
萌エディタ
"C:\Program Files\moeditor\moe.exe" "%F" -m %L,%C
gedit
/usr/bin/gedit +%L "%F"
Vim(vi)
/usr/bin/vim +%L "%F"
Emacs
/usr/bin/gnuclient +%L "%F"

なお、%Lは行番号、%Cは列番号、%Fはファイルのパスへと、それぞれ自動的に置換されます。

タグ: Mozilla | テスト | UxU
2009-04-01

UxU(UnitTest.XUL)を利用したFirefoxアドオンのデバッグの例

Firefoxアドオン開発者向け自動テストツールのUxUは、新たに発見したバグの修正にも活用することができます。本日リリースされたXUL/Migemo バージョン0.11.7で行われた修正の場合を例に、実際のデバッグ作業の流れを解説します。

状況

XUL/Migemoは、Firefoxで表示しているページ内のテキストを検索する機能を提供するアドオンですが、検索を開始する際に、「現在のスクロール位置から検索を開始する」という処理を含んでいます。0.11.6以前では、この機能を使用している時に、ページ先頭から検索が始まるべき場面で、先頭以外の場所から検索が始まってしまうことがあるという問題が起こっていました。

再現条件の特定

いくつか条件を変えて調査した結果、スクロールが発生しているページでは期待通りの結果になっているのに対して、スクロールが全く発生していないページ(ページ全体がウィンドウの現在の大きさの中に収まっているページ)では期待と異なる結果になっていることが判明しました。

原因箇所の特定

前述の処理の肝となっているのは、pXMigemoFindクラスfindFirstVisibleNode()というメソッドです。このメソッドは、渡されたフレーム(DOMWindow)において現在のスクロール位置で見えている最初の要素を検索して返す物です。このメソッドの戻り値を確認した所、前述の条件下では戻り値が期待と異なっている事が判明しました。

このことから、今回主な修正対象になるのはこのfindFirstVisibleNode()というメソッドであるということになります。

テストケースの作成

上記メソッドの実装を見直す前に、UxU用のテストケースを作成します。これにより、これから行う修正で目指すべきゴールが明確になります。つまり、このテストが成功する状況まで持って行くことが、今回の修正のゴールとなります。

テストケースはJavaScriptのコードだけで完結する場合もありますが、今回のような場合は実際のWebページを使ってテストを行う必要があります。そこで、問題が発生する条件と発生しない条件の両方の事例としてHTMLドキュメントを用意します。

これらのドキュメントを使い、shortPage.htmlではスクロールが発生せずlongPage.htmlではスクロールが発生する、という条件の下でテストを行うテストケースをこれから作成することになります。

ところで、現在の実装で問題が起こっている場合だけでなく、すでに正常に動いている場合の事例も同時に作成していることに気がついたでしょうか? 両方の場合を常にテストすることで、「ある問題を修正したら、今度は、今までは正常に動いていた物が動かなくなった」という状況、いわゆる後退バグを未然に防ぐことができます。 *1

それではテストケースを作成します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
utils.include('pXMigemoClasses.inc.js');

var findModule;

function setUp()
{
  yield utils.setUpTestWindow();
  findModule = new pXMigemoFind();
  findModule.target = utils.getTestWindow().gBrowser;
}

function tearDown()
{
  findModule.destroy();
  findModule = null;
  utils.tearDownTestWindow();
}

function testFindFirstVisibleNode()
{
  // ここに実際のテスト内容を記述する
}

pXMigemoFindクラスの単体テストはまだ作成されていなかったので、今回はsetUpとtearDownから作成します。すでに作成済みのテストケースがあり、それにテスト項目を追加する場合、この作業は不要となります。

pXMigemoFindクラスはtabbrowser要素を用いて初期化する必要があるため、setUpでテスト用のFirefoxウィンドウを開き、そのウィンドウのtabbrowser要素で初期化します。また、tearDownではsetUpで開いたテスト用のFirefoxウィンドウを閉じて、pXMigemoFindクラスのインスタンスを破棄します。UxUは関数名を見て自動的にその種類を判別するため、これだけで、これらの関数はテストの初期化処理と終了処理として認識されるようになります。

なお、インクルードしているpXMigemoClasses.inc.jsというファイルは、pXMigemoFindクラスやそのクラスが依存しているすべての関連クラスの定義を読み込む物です。

次に、テストの内容を作成していきます。

1
2
3
4
5
6
7
8
function testFindFirstVisibleNode()
{
  var win = utils.getTestWindow();
  win.resizeTo(500, 500);
  assert.compare(200, '<', utils.contentWindow.innerHeight);

  // ここに実際のテスト内容を記述する
}

関数名を「test」で始めると、その関数はテストの内容として自動的に認識されます。

最初に、ウィンドウの大きさを調整して、「shortPage.htmlではスクロールが発生せずlongPage.htmlではスクロールが発生する」という条件を整えておきます。ここでは、テスト自体が期待通りの条件下で実行されていることを確認するために、assert.compare()でテスト用フレームの大きさを調べています。

1
2
3
4
5
6
7
8
9
  yield utils.addTab(baseURL+'../res/shortPage.html', { selected : true });

  var frame = utils.contentWindow;
  var node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame);
  assert.equals(utils.contentDocument.documentElement, node);

  item = frame.document.getElementById('p3');
  node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame);
  assert.equals(item, node);

テスト用のドキュメントを新しいタブで開き、findFirstVisibleNode()メソッドの返り値が期待通りかどうかを検証します。1つ目の検証は前方検索、2つ目は後方検索です。

同様にして、スクロールが発生する場合のテストも作成します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  yield utils.addTab(baseURL+'../res/longPage.html', { selected : true });

  frame = utils.contentWindow;
  node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame);
  assert.equals(utils.contentDocument.documentElement, node);

  item = frame.document.getElementById('p10');
  frame.scrollTo(0, item.offsetTop);
  node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame);
  assert.equals(item, node);

  frame.scrollTo(0, item.offsetTop - frame.innerHeight + item.offsetHeight);
  node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame);
  assert.equals(item, node);

  item = frame.document.getElementById('p21');
  frame.scrollTo(0, item.offsetTop - frame.innerHeight + item.offsetHeight);
  node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame);
  assert.equals(item, node);

スクロールされていない時、ページ途中までスクロールされている時、ページの最後までスクロールされている時の各ケースで前方検索と後方検索を行い、結果を検証します。

ここで、かなりの部分のコードが重複していることに気がついたでしょうか。このような場合、それぞれの検証の前で重複しているコードと検証とをひとまとめにして実行する関数(カスタムアサーション)を定義しておくと、テスト項目の追加が簡単になります。以下は、カスタムアサーションを使ってここまでのテスト内容を書き直した物です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function testFindFirstVisibleNode()
{
  var win = utils.getTestWindow();
  win.resizeTo(500, 500);
  assert.compare(200, '<', utils.contentWindow.innerHeight);

  function assertScrollAndFind(aIdOrNode, aFindFlag)
  {
    var frame = utils.contentWindow;
    var item = typeof aIdOrNode == 'string' ? frame.document.getElementById(aIdOrNode) : aIdOrNode ;
    frame.scrollTo(
      0,
      (aFindFlag & findModule.FIND_BACK ?
        item.offsetTop - frame.innerHeight + item.offsetHeight :
        item.offsetTop
      )
    );
    var node = findModule.findFirstVisibleNode(aFindFlag, frame);
    assert.equals(item, node);
  }

  yield utils.addTab(baseURL+'../res/shortPage.html', { selected : true });
  assertScrollAndFind(utils.contentDocument.documentElement, findModule.FIND_DEFAULT);
  assertScrollAndFind('p3', findModule.FIND_BACK);

  yield utils.addTab(baseURL+'../res/longPage.html', { selected : true });
  assertScrollAndFind(utils.contentDocument.documentElement, findModule.FIND_DEFAULT);
  assertScrollAndFind('p10', findModule.FIND_DEFAULT);
  assertScrollAndFind('p10', findModule.FIND_BACK);
  assertScrollAndFind('p21', findModule.FIND_BACK);
}

テストの実行

テストケースが完成したら、テストを実行してみましょう。実装の修正前なので、当然、このテストは「失敗」という結果が出ます。ですが、この段階では問題ありません。これから、このテストの結果が「成功」になることを目指して実装を修正していきます。

実装の修正

実装の修正内容については省略します。良いアイディアを思いついたら、それを実装に反映して、再度テストを実行してみましょう。テストに成功しないようであれば、まだ修正が必要です。

何度テストを実行しても結果が「成功」になるようになれば、実装の修正はひとまず完了です。修正内容をリポジトリにコミットするなり、修正済みの新しいバージョンとしてリリースするなりしましょう。

新たな問題が発覚した時や、仕様が変わった時は

以上で、今回発見された問題の修正は完了しました。

しかし、上記のテストだけではテストしきれないような、より複雑な条件でだけ発生するバグが新たに見つかるかもしれません。そのような場合は、テストを新たに追加して、それらがすべて「成功」するようになるまで修正してやりましょう。その時はもちろん、他のテストも同時に実行することを忘れないようにしましょう。

また、開発を進める中で、他の部分に加えた変更の影響を受けて上記のテストが失敗するようになることがあるかもしれません。そのような場合、再びテストが通るようになるように実装を修正する必要があります。

実装の仕様を変更した時にも、ここで作成したテストケースは「成功」しなくなる場合があります。このような場合は「ゴール」自体が変わったということになりますので、実装ではなくテストケースの側を修正しなくてはなりません。

まとめ

自動テストを使った開発では、メンテナンスする必要があるコードが「実装」と「テストケース」の2つになるため、一見すると、手間だけが倍増するように思えるかもしれません。

しかし、一連のテスト手順を自動化しておくことで、人の手によるテストでは見落としてしまいかねない思わぬ後退バグの発生に迅速に気づけるようになります。後退バグの発生に日々頭を悩ませている人は、是非、自動テストを開発に取り入れてみてください。

*1 もちろん、後退バグの発生自体は未然には防ぎきれません。しかし、後退バグの発生にすぐ気がつくことができれば、コミットやリリースの前にその後退バグを修正できるため、他の共同開発者やユーザには後退バグの影響を与えずに済むようになります。

タグ: Mozilla | テスト | UxU
2008-11-17

UxUで始めるFirefoxアドオンの自動テスト

Firefox用アドオンやXULRunnerアプリケーションなどのいわゆるXULアプリケーションは、ロジック部を主にJavaScriptで記述するため、script.aculo.usのテスト関連機能などJavaScript用のテストツールを使って自動テストを行えます。しかし、一般的なJavaScript用のテストツールはWebアプリケーションをテストすることを主眼において開発されているため、利用できる機能に制限があったり、HTMLではなくXULを使用するXULアプリケーションのテストでは不具合が生じたりする場合があります。

UxU(UnitTest.XUL)は、著名なXULアプリケーション開発支援ツールであるMozLabをベースにクリアコードで開発を行っている自動テスト実行ツールです。FirefoxやThunderbirdなどのXULアプリケーション上での利用を前提としているため、前述のような制限や問題を気にすることなく自動テストを記述できる、便利なヘルパーメソッドを利用できる、などの特長があります。

テストの記述方法やヘルパーメソッドの一覧はUxUの紹介ページに情報がありますが、ここではFirefox用アドオンのXUL/Migemoのテストを実例として示しながら、UxUによる自動テストの方法について簡単にご紹介をしたいと思います。Subversionのリポジトリ内にテストのサンプル用に用意されたタグがありますので、まずはこちらから必要なファイル一式をチェックアウトしておいてください。

クラスのユニットテスト

まずは最も簡単な例として、「tests」→「unit」とフォルダを辿った中にあるdocShellIterator.test.jsを見てみましょう。

XUL/MigemoはFirefoxのページ内検索を拡張するアドオンですので、フレーム内に検索語句が見つからなかったときは、子フレームや親フレームを検索対象として自動的に再検索を行うといった処理が必要になります。docShellIterator.test.jsでは、このための処理を担当するクラス「DocShellIterator」が正しく機能するかどうかをテストしています。

include
1
utils.include('../../components/pXMigemoFind.js', null, 'Shift_JIS');

冒頭では、ヘルパーメソッドのutils.includeを使って、DocShellIteratorクラスが定義されているファイルpXMigemoFind.jsの内容を取り込んでいます。第3引数で読み込むファイルのエンコーディングを指定していますが、これはファイルの中に含まれる日本語のコメントがShift_JISになっているためです。

なお、何らかの事情でそのままファイルをincludeできない(includeするとまずい)場合には、ファイルの内容をテキストとして読み込んで加工した後に評価するという方法もあります。上の例は、以下のように書き換えても同様に動作します。

1
2
3
4
5
var extract;
eval('extract = function() { '+
     utils.readFrom('../../components/pXMigemoFind.js') +
     '; return DocShellIterator }');
var DocShellIterator = extract();

utils.readFromはファイルの内容を文字列として返しますので、replaceなどを使って邪魔な部分を消してやれば、そのままではincludeできないファイルから必要な部分だけを取り出して評価できます。

テストケースの定義

このファイルにはテストケースが一つだけ定義されています。テストの前処理(setUp)と後処理(tearDown)は以下の通りです。

1
2
3
4
5
6
7
8
9
10
11
12
var DocShellIteratorTest = new TestCase('DocShellIteratorのユニットテスト');

DocShellIteratorTest.tests = {

  setUp : function() {
    yield Do(utils.loadURI('../res/frameTest.html'));
  },

  tearDown : function() {
    iterator.destroy();
    yield Do(utils.loadURI());
  },

setUpとtearDownは、それぞれのテストを実行する前と後に毎回実行されます。このテストケースでは、テスト実行前に「テストに使用するフレームにHTMLファイルを読み込む」という処理を行い、テスト終了後に「フレームに空のページを読み込んで内容を破棄する」という処理を行うことで、毎回必ずクリーンなテスト環境を準備するようにしています。

setUpの中では、「フレームに指定したページを読み込み、読み込みが完了するのを待って次に進む」といった処理待ちを行うために、ヘルパーメソッドとyield式を使用しています。yieldは本来はJavaScript 1.7で導入されたジェネレータのための物ですが、UxUではこれを応用して処理待ちを実現しています。JavaScriptで処理待ちというと、タイマーやonloadのようなイベントハンドラを使う方法が真っ先に思い浮かぶと思いますが、yield式を使用すれば、それらの場合に比べて処理の流れをフラットに記述することができます。

テストの内容

個々のテストの定義を見てみましょう。以下のテストでは、DocShellIteratorクラスのインスタンスを生成して、検索対象のフレームを移動する処理を実際に行い、処理結果が期待されたものと等しいかどうかをテストしています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
'前方検索': function() {
  // 1番目のフレームを初期状態としてインスタンス生成。
  iterator = new DocShellIterator(content.frames[0], false);
  // 初期化は成功したか?
  assert.initialized(iterator, content.frames[0]);

  // 次のフレームに移動するメソッドを実行。
  assert.isTrue(iterator.iterateNext());
  // フォーカスは正常に2番目のフレームに移動したか?
  assert.focus(iterator, content.frames[1]);
  assert.isFalse(iterator.isInitial);
  // もう一度フレームを移動。
  // 3番目のフレームは無いので、一巡して最上位のフレームにフォーカスする。
  assert.isTrue(iterator.iterateNext());
  // フォーカスは正常に最上位のフレームに移動したか?
  assert.focus(iterator, content);
  assert.isFalse(iterator.isInitial);
  // もう一度フレームを移動。1番目のフレームに戻る。
  assert.isTrue(iterator.iterateNext());
  // フォーカスは正常に1番目のフレームに移動したか?
  assert.focus(iterator, content.frames[0]);
},

処理が成功したかどうかを確認する手続きを、アサーション(宣言)と呼びます。assert.isTrue、assert.isFalse、assert.equalsなどのアサーション用ヘルパーメソッドは、実行されると渡された値を評価します。実際に渡された値が期待された値と等しければそのまま処理を続行しますが、値が異なっていた場合は「アサーションに失敗した」という内容の例外を発生させてテストの実行が中断されます。これらの例外やメソッド実行時に発生した未知の例外はUxUのインターフェース上に逐次表示され、例外が発生した行のスタックトレースを後で辿ることができるため、どの時点で問題が起こったのか、どこまでは予想通りに正常に動いたのかを詳しく調べてデバッグに役立てられます。

UxUのページのアサーション一覧にないassert.initializedやassert.focusはこのテスト専用に定義したカスタムアサーションです。その実体は、このファイルの冒頭でassertオブジェクトに追加されている新しいメソッドで、以下のように、内部でより単純なアサーションを複数行っています。

1
2
3
4
5
6
7
8
9
assert.focus = function(aIterator, aFrame) {
  assert.equals(aFrame.location.href, aIterator.view.location.href);
  assert.equals(aFrame, aIterator.view);
  assert.equals(aFrame.document, aIterator.document);
  assert.equals(aFrame.document.body, aIterator.body);
  var docShell = getDocShellFromFrame(aFrame);
  assert.docShellEquals(docShell, aIterator.current);
  assert.isTrue(aIterator.isFindable);
}

複数の条件を満たしているかどうかを確認する必要がある場合、そのままテストを記述すると、同じコードがテストケースの中に大量に並んでしまいます。そういった一連の処理はカスタムアサーションとしてまとめておけば、テストケースの内容を綺麗に見やすくできます。

テストの実行

テストの実行は、MozRepl互換のUxUサーバを起動してコンソールから接続して行うか、MozUnit互換のテストランナーを起動して行います。「ツール」メニューから「UnitTest.XUL」→「MozUnitテストランナー」と辿り、テスト実行用のGUIを起動します。

UxUのテスト実行用GUIは、テストケースのファイルのドラッグ&ドロップを受け付けます。実行したいテストのファイル(docShellIterator.test.js)をウィンドウにドラッグ&ドロップすると「作業中のファイル」欄のファイルのパスがドロップされたファイルのものになりますので、後は「実行」ボタンを押すだけで自動テストを実行できます。

テストの現在の実行状況はプログレスメーターで表示されます。アサーションに失敗したり予期しないエラーが起こったりするとプログレスメーターが赤くなりますが、正常にテストが成功すれば緑色のままです。すべてのテストケースの実行結果が緑となることを目指して開発や修正を進めていきましょう。

まとめ

このように、一連の処理と期待される結果をまとめておき、自動的にテストできるようにしておくと、開発した機能がきちんと動作しているかどうかを人手を煩わさず機械的に確かめられます。一通りのテストケースを作成するにはそれなりの手間と時間を要しますが、一度作成しておけばテストは何度でも簡単に実行できますので、うっかりミスによるエンバグを未然に防ぐ*1ことができます。

Firefox用のアドオンはFirefox自身が持っている機能と連携して動作するように作られることが多く、自動テストを作るのはなかなか大変です。UxUには処理待ち機能をはじめとした多くの便利な機能があり、「このような操作を行った時に、こういう結果になる」といった人間の操作をシミュレートする形の複雑なテストでも、処理の流れを追いやすい形で記述できます。Firefox用アドオンの自動テスト作成にはまさにうってつけと言えるでしょう。

*1 エンバグしてしまってもすぐにそれに気がつくことができる

タグ: Mozilla | テスト | UxU
2008-06-11

2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|
タグ:
RubyKaigi 2015 sponsor RubyKaigi 2015 speaker RubyKaigi 2015 committer RubyKaigi 2014 official-sponsor RubyKaigi 2014 speaker RubyKaigi 2014 committer RubyKaigi 2013 OfficialSponsor RubyKaigi 2013 Speaker RubyKaigi 2013 Committer SapporoRubyKaigi 2012 OfficialSponsor SapporoRubyKaigi 2012 Speaker RubyKaigi2010 Sponsor RubyKaigi2010 Speaker RubyKaigi2010 Committer badge_speaker.gif RubyKaigi2010 Sponsor RubyKaigi2010 Speaker RubyKaigi2010 Committer
SapporoRubyKaigi02Sponsor
SapporoRubyKaigi02Speaker
RubyKaigi2009Sponsor
RubyKaigi2009Speaker
RubyKaigi2008Speaker