レベル: 中級 Elliotte Rusty Harold (elharo@metalab.unc.edu), Adjunct Professor, Polytechnic University
2005年 3月 03日 包括的なユニットテスト・スイートは堅牢なプログラムのために必須なものです。しかし、そのテスト・スイートが、行うべきテストをすべて行っているかどうか、どのように分かるのでしょう? Ivan Mooreの書いたJUnitテスターであるJesterは、テスト・スイートの問題を発見するのに優れており、ユニークな方法でコードベースの構造を分析します。この記事ではElliotte Rusty HaroldがJesterを紹介し、最善の結果を得るための使い方を説明します。
テスト・ファースト・プログラミング(test-first programming)は、エクストリーム・プログラミング(XP: Extreme Programming)の中で最も反論が少なく、また広く受け入れられているものです。ほとんどの専門的Javaプログラマーはこれまで既に、テストに関するバグを捕らえているでしょう(参考文献に、「test infected(テスト熱中症)」についての情報が他にもあります)。JUnitは、Javaコミュニティーでデファクトとなっているテスト・フレームワークであり、包括的なJUnitテスト・スイートを持たないシステムは完全とは言えません。皆さんのプロジェクトに包括的なテスト・スイートが入っていれば、「おめでとうございます」。皆さんは、動作するという望みが多少はある、良質なソフトウェアを作っていることになります。しかしながら、大部分のコードベースは非常に複雑です。皆さんはあらゆるメソッドをテストし、あらゆる分岐をたどったでしょうか。それを確認していないとすると、そうしたメソッドや分岐が実行された場合に、そのアプリケーションはどのように振る舞うでしょう。
コード・カバレッジ
コードをテストする、というレベルを超えた次のステップは、コード・カバレッジ(code coverage: テスト対象範囲) ツールでテストを調べることです。コード・カバレッジは、どのくらい多くのコードが一連のテストで網羅されるかを見るための方法の一つです。テスト結果に自信を持つためには、プログラムが全体としてテストされたということだけではなく、それぞれのメソッドが、可能性のあるすべての条件の下でテストされたことを知っている必要があります。通常そうした測定は、JVMDI(Java Virtual Machine Debugging Interface)やJVMTI(Java Virtual Machine Tool Interface)を通して、あるいはバイトコードを直接計測し、テスト実行中にテストをモニターすることで行われています。少なくとも一度は実行されていないステートメントは、テストされていないことになります。
この手法は、CloverやEMMAなどのツール(参考文献)で採用されており、未テストのステートメントを見つけるには貴重ですが、これでは充分ではありません。あるステートメントをテスト・スイートが実行していない、ということを知れば、テストされていないことの証明にはなりますが、その逆は真ではありません。あるコード行が実行されたとしても、テストされたことには必ずしもなりません。テストが、コード行が正しい結果を生成するか否かをチェックしないのは、ごく普通にあり得ることです。
もちろん、それぞれのステートメントの結果を独立に証明するようなテスト・スイートなど、誰も書くはずがありません。そんなことをすると、他の問題にもまして、カプセル化に違反することになります。私達は、ある入力がありさえすれば、そのメソッドに期待された結果を生成するようにメソッド中の各コード行は適切に動作しているに違いない、と想定しがちです。しかし、その想定は正当化できるものではありません。例えば、可能性のある入力すべてをテストしておらず、従ってエッジ条件を処理するためのコードをテストしていないとしたら、どうでしょう。各コード行はテストされているかも知れませんが、本当のバグは見逃しているかも知れないのです。
Jesterの紹介
 |
フールプルーフではない
Jesterの手法はフールプルーフではありません。ですから大量のfalse positivesをレポートしがちです。例えば、ステートメントSystem.out.println("Copyright 2005 Elliotte Rusty Harold") を、System.out.println("Copyright 3005 Elliotte Rusty Harold") に変更した後で、何も壊れなかったとレポートするかも知れません。しかしfalse positivesは通常、フィルターで簡単に除去できます。また多くの場合には、この例のようなものが本当にfalse positivesかどうか疑うだけの充分な理由があるものです。たとえば、copyrightの日付が3005になっている問題は、テスト・スイートが発見すべきバグだと言うことができます。
|
|
ここでJesterが登場するのです。Cloverのような通常のコード・カバレッジ・ツールと異なり、Jesterは、どのコード行が実行されたかを監視しません。その代わりにJesterはソースコードを変更して再コンパイルし、テスト・スイートを実行して、何かが壊れないかを調べるのです。例えば、1を2に、あるいはif (x > y) をif (false)に変更します。テスト・スイートがこの変更に十分な注意を払わっていなければ、テストが一つ見逃されていることになります。
オープンソースのJaxen XPathツール(参考文献)に対してJesterを使うことで、Jesterの動作を説明して行きましょう。Jaxenには、(あまりカバレッジが完璧ではありませんが)JUnitベースのテスト・スイートがあります。
はじめに
Jesterを実行する前に、何も修正しないソースコードで、すべてのユニット・テストをパスする必要があります。もしパスしないと、Jesterによる変更によって何かが壊れたのかどうか、Jesterには分かりません。(私はこれを示すために、テストケースの対象として書いた一つのバグを直さなければなりませんでしたが、まだ追跡しきってつぶすには至っていません。)
Jesterは、IDEとあまりうまく(あるいは全く)統合できません。そのため、CLASSPATH とディレクトリーを適切に設定して、テストがパスするようにすることが重要です。テスト・スイートを実行するために必要な、具体的なコマンドラインはプロジェクトごとに異なります。Jaxenテストは特定なテスト・ファイルを指す相対URLを使うため、テストはjaxenディレクトリー内から実行する必要があります。私は最終的に、次のようにJaxenテストを実行しました。
$ java -classpath ../jester136/jester.jar:target/lib/junit-3.8.1.jar
:target/lib/dom4j-core-1.4-dev-8.jar:target/lib/jdom-b10.jar
:target/lib/xom-1.0d21.jar:target/test-classes:target/classes
junit.textui.TestRunner org.jaxen.JaxenTests
|
Jesterを実行する前に、テスト・スイートに対する制約を、もう一つクリアする必要があります。テスト・スイートは、テストがフェールしない限り、System.err に対して何も出力してはいけません。Jesterはテストが成功したかどうかを、何が出力されたかをチェックして判断します。ですからSystem.err へのプログラム出力があると、Jesterを混乱させてしまうことが多いのです。
テスト・スイートがフェールせずに実行したら、ソース・ツリーのコピーを作ります。Jesterはコードの中に勝手にバグを注入することを忘れないでください。ですから、もし何かがおかしくなった場合に、バグをそのままに放置したくありません(これは、ソースコード・コントロールを使っている場合には、大した問題になりません。皆さんはソースコード・コントロールを使っていますよね? 使っていないのであれば、この記事を読むのを中断し、ソースコードをCVSあるいはSubversionリポジトリーに即チェックインしてください。)
Jesterを実行する
Jesterを実行するためには、クラスパスにjester.jarとjunit.jarの両方がある必要があります(JUnitはJesterに同梱されていないため、別にダウンロードする必要があります)。Jesterは、そのコンフィギュレーション・ファイルをクラスパスの中で探すので、メインのJesterディレクトリーもクラスパスに置く必要があります。当然のことですが、テスト対象のアプリケーションが必要とする他のJARやディレクトリーも追加する必要があります。メインのクラスは、です。このプログラムに渡す引き数は、テスト・アプリケーションに対するテスト・スイート・クラスの名前です。(Jaxenには、全テストを実行するクラスが一つもないため、私はテスト・スイート・クラスを書かなければなりませんでした。)Jesterの動作は、必要なJARファイルやディレクトリーをjre/lib/extに追加したり、-classpath で参照したりするよりも、すべてCLASSPATH 環境変数に追加した方が、ずっと信頼性が高くなります。下記は、私がJaxenに対する最初のテストをどのように実行したかを示しています。
$ export CLASSPATH=src2/java/main:../jester136/jester.jar:../jester136
:target/lib/junit-3.8.1.jar:target/lib/dom4j-core-1.4-dev-8.jar
:target/lib/jdom-b10.jar:target/lib/jdom-b10.jar:target/lib/xom-1.0d21.jar
:target/test-classes:target/classes
$ java jester.TestTester org.jaxen.JaxenTests src2/java/main
|
Jesterは、単に一つのファイルをチェックするだけの場合も、ゆっくりと動作します。図1のような、進行を示すダイアログが表示され、何をしているのか、そして完全にハングアップしていないことを知らせるために、System.out に進行状況が出力されます。
図1. Jesterの進行状況
最初の数分後(あるいは、完全なテスト・スイート実行に充分な時間の、どちらか長い方)に何も出力が現れない場合には、恐らくJesterは ハングアップしています。可能性の高い原因としては、クラスパスの問題があります。すべてが順調に行けば、リスト1に示すような出力が見えるはずです。
リスト1. Jesterの出力
Use classpath: src2/java/main:../jester136/jester.jar
:../jester136:target/lib/junit-3.8.1.jar:target/lib/dom4j-core-1.4-dev-8.jar
:target/lib/jdom-b10.jar:target/lib/jdom-b10.jar:target/lib/xom-1.0d21.jar
:target/test-classes:target/classes
...
src2/java/main/org/jaxen/BaseXPath.java
- changed source on line 192 (char index=7757) from 1 to 2
answer.size() == ?1 )
{
Object first = answ
src2/java/main/org/jaxen/BaseXPath.java
- changed source on line 691 (char index=24848) from 0 to 1
return results.get( ?0 );
}
}
lots more output...
src2/java/main/org/jaxen/BaseXPath.java
- changed source on line 691 (char index=24848) from 0 to 1
return results.get( ?0 );
}
}
10 mutations survived out of 11 changes. Score = 10
took 1 minutes
|
リスト1から分かる通り、BaseXPath クラスは、あまりよくにテストされていません。Jesterは、このクラスに対して11の変更を行っていますが、テストをフェールさせられたのは、1つの変更の場合のみです。これらの幾つかはfalse positiveですが、11の中の1つよりも良くできるはずです。
次のステップは、Jesterがテスト・スイートを壊すことなく変異させたコードを調べ、そのコード用にテストを書く必要があるかどうかを判断することです。Jesterは、図1 に示すGUI(残念ながらディスプレイ・モニター無しで実行することはできません)で何を変更しているかを表示し、リスト1に示す出力をコンソールに出力し、変更しても何も壊すことがなかった変更のリストを含む、リスト2のようなXMLファイルを生成します。
リスト2. JesterのXML出力
<JesterReport>
<JestedFile fileName="src2/java/main/org/jaxen/BaseXPath.java" absolutePathFileName=
"/Users/elharo/Documents/articles/jester/jaxen/src2/java/main/org/jaxen/BaseXPath.java"
numberOfChangesThatDidNotCauseTestsToFail="8" numberOfChanges="11" score="28">
<ChangeThatDidNotCauseTestsToFail index="7691" from="if (" to="if (true ||"/>
<ChangeThatDidNotCauseTestsToFail index="7691" from="if (" to="if (false &&"/>
<ChangeThatDidNotCauseTestsToFail index="7703" from="!=" to="=="/>
<ChangeThatDidNotCauseTestsToFail index="7754" from="==" to="!="/>
<ChangeThatDidNotCauseTestsToFail index="7757" from="1" to="2"/>
<ChangeThatDidNotCauseTestsToFail index="7826" from="if (" to="if (true ||"/>
<ChangeThatDidNotCauseTestsToFail index="7826" from="if (" to="if (false &&"/>
<ChangeThatDidNotCauseTestsToFail index="24749" from="if (" to="if (false &&"/>
</JestedFile></JesterReport>
|
Jesterの行番号レポートは多くの場合、大きく外れています。ですから変更されたコードは、コンソール出力で探した方が無難です。下記は、リスト1のレポートから取り出した変更の一つです。
src2/java/main/org/jaxen/BaseXPath.java
- changed source on line 691 (char index=24848) from 0 to 1
return results.get( ?0 );
}
}
|
この変更は、このメソッドの中の、このクラスの終わりの方にあることが判明します。
protected Object selectSingleNodeForContext(Context context) throws JaxenException
{
List results = selectNodesForContext( context );
if ( results.isEmpty() )
{
return null;
}
return results.get( 0 );
}
|
テスト・スイートを素早く眺めると、確かにselectSingleNodeForContextを呼ぶテストが何もないことが分かります。そこで次のステップは、このメソッドに対するテストを書くことです。このメソッドは保護されているため、テストが直接呼ぶことはできません。保護されたメソッドをテストするために、(多くの場合、内部クラスとして)サブクラスを書かなければならない場合が時々あります。しかしこの場合は、ちょっとgrepを使うと、このメソッドが、同じクラスにある他の2つのパブリック・メソッド、stringValue とnumberValueによって直接呼び出されていることがすぐに分かります。この両方を使ってテストすればよいのです。
public void testSelectSingleNodeForContext() throws JaxenException {
BaseXPath xpath = new BaseXPath("1 + 2");
String stringValue = xpath.stringValueOf(xpath);
assertEquals("3", stringValue);
Number numberValue = xpath.numberValueOf(xpath);
assertEquals(3, numberValue.doubleValue(), 0.00001);
}
|
最後のステップは、テスト・ケースを実行し、パスすることを確認することです。下記はその結果です。
java.lang.NullPointerException
at org.jaxen.function.StringFunction.evaluate(StringFunction.java:121)
at org.jaxen.BaseXPath.stringValueOf(BaseXPath.java:295)
at org.jaxen.BaseXPathTest.testSelectSingleNodeForContext(BaseXPathTest.java:23)
|
Jesterがバグを捕らえました! このメソッドは、想定したようには動作しなかったのです。もっと面白いことに、このバグを調べてみると、潜在的な設計欠陥が分かりました。BaseXPath クラスは恐らく、具象クラスではなく抽象クラスであるべきなのです。誓って言いますが、私はこのバグを見せるためにこの例をわざわざ選んだわけではありません。私は単に、BaseXPath が最上位レベルのorg.jaxenパッケージの中で最初のクラスなのでBaseXPathから始めただけです。また、テスト対象のメソッドとしてselectSingleNodeForContext を選択したのは、それがJesterのレポートした最後のエラーだったからです。私は本当に、このメソッドには何も悪いところはないと思っていたのですが、間違っていました。もし何かがテストされずにいたら、それは壊れているもの、と思ってみてください。Jesterが、何が壊れているかを教えてくれるのです。
次のステップは明確です。つまりバグを修正することです。(Jesterが作業を行っているソース・ツリーのコピーと、実際のツリーの両方を必ず修正してください。)次に、このクラスがもはやどんな変異も回避しないこと、あるいは回避する変異は無関係なことが明白になるまで、このクラスに対してJesterを繰り返し再実行します。私がこのバグに対するテストを追加(そして修正)した後は、Jesterは11の変異のうち、8のみが未検出だったとレポートしています(リスト2に示されています)。デバッグではよくあることですが、一つのバグを修正すると、他の幾つかのバグも修正(あるいは発見)できるものです。
Jesterのパフォーマンス
Jesterはコードベースを再コンパイルし、Jesterが行う変更それぞれに対してテスト・スイートを再実行するため、実行時間はCloverなど一般的なツールに比べて、何桁か遅くなります。従って、パフォーマンスに多少注意する必要があります。幾つかの手法を使うと、Jesterの実行を速くすることができます。
まず、Jesterの実行時間の大部分をコンパイルが費やしている場合には、高速のコンパイラーを試します。javacではなくJikesを使うことで、明らかにスピードが速くなったと、多くのユーザーが報告しています(参考文献)。Jesterが使うコンパイル・コマンドは、Jesterのメイン・ディレクトリーにあるjester.cfgファイルを使って変更することができます。
次に、テスト・スイートのプロファイルを作成し、最適化します。通常は、どのくらい速くユニット・テストが実行するかを大して気にする必要はありませんが、Jesterがテスト・スイートを何千回も実行することを考えれば、わずかな節約も何倍かの節約になります。特に、テスト・スイートの中で、通常のコードでは起こらない問題を探します。JUnitは、実行される全メソッドの全フィールドを再初期化するため、フィールドからテスト・データを引き出してローカル変数に入れると、そのフィールドがテスト・クラス中の全メソッドで使われているのでない場合は、大幅にスピードが速くなります。もしコード重複が起きて皆さんのスタイル感覚に反するのであれば、テスト・スイートを小さな、モジュラーなクラスに分割し、そのそれぞれで、すべての初期データがすべてのテスト・メソッドで共有されるようにします。
第3に、テスト・スイートのsuite メソッドを再構成し、最も繊細なテスト(変更すると一番壊れやすいもの)が、それよりも繊細ではないテストの前に実行されるようにします。一つでもテストがフェールしたことを検出すると、Jesterは実行を打ち切ります。ですから、できるだけ早い時期にフェールさせることによって、時間を要する他のテストを省略するのです。
第4に、同様の理由から、テストがフェールする可能性が同程度であれば、最も高速なテストを最初にします。概略の実行時間でテストを並べ替えるのです。純粋にメモリーで実行するテストの後に、ディスクをアクセスするテスト、その後にLANにアクセスするテスト、その後にインターネットにアクセスするテスト、というようにします。あるテストが特に遅い場合には、(false positiveの数は増えるかも知れませんが)そのテストをしないようにします。XOM(Java言語でXMLを処理するためのAPI)用のテスト・スイートでは、約50あるテスト・クラスの中の数個だけで実行時間の90%以上を占めてしまいます。Jesterの実行パフォーマンスが10倍向上するならば、これらを削除します。
最後に、そして最も重要なことは、コードベース全体を一度にテストしないことです。一度に一つのクラスのみをテストするように制限し、その一つのクラスのコード・カバレッジの隙間を露出しそうなテストのみを実行します。全てのクラスをテストしてもわずかに時間が長くなるだけかも知れませんが、こうすることによって、Jesterが数日かかって実行完了するのを待つことなく、即座に隙間を埋め、バグを修正することができるのです。
まとめ
Jesterは、アジャイル・プログラミングのツールボックスにぜひ追加すべきものです。他のルールでは不可能な、コード・カバレッジの隙間を見つけるので、バグの発見や修正に直結できます。Jesterでコードベースをテストすることによって、より堅牢なソフトウェアを作ることができるのです。
参考文献
著者について
記事の評価
|