レベル: 初級 Malcolm Davis (malcolm@nuearth.com), Consultant
2000年 11月 01日 ソフトウェア開発においてやり方を少し変えることで、ソフトウェア品質の大幅な向上につなげることができます。単体テストを開発プロセスに取り入れ、プロジェクト全体を通すと、時間と労力がどの程度節約できるかを確認してみましょう。ここでは、単体テストのメリット、特にAntとJUnitを使用してコード・サンプルを単体テストする場合のメリットを探ります。
卓越した開発プロセスを実現する基本の1つはテストです。作業の検証は、どのような専門的職業でも大切な部分です。医者は、血液検査で診断を確認することができます。ボーイング社は、777型機の開発時に飛行機の構成部品のテストを行いました。ソフトウェア開発でも同じことが言えます。
以前は、アプリケーションにおけるGUIとビジネス・ロジックとの緊密な関連のせいで、自動化テストの実施に限界がありました。しかし、今では抽象のレイヤーを介して、インターフェースからビジネス・ロジックを切り離すことができるようになったので、GUIを介する手作業のテストに代わって、コードの単一モジュールの自動化テストを利用できるようになりました。
統合開発環境 (IDE) では、コードを入力したときにエラーを表示したり、Intelベースでクラス内のメソッドを簡単に検索したりすることができ、構文カラー表示など、さまざまな機能を利用することができます。したがって、変更を加えたコードをコンパイルする前に、クラスをbuildするのも良い考えですが、その修正によって機能が停止してしまう可能性もあります。
変更によるバグは、開発者の泣きどころです。コードを修正したときにバグが入り込み、コンパイル後にユーザー・インターフェースを介してコードを手作業でテストするまで、そのバグに気付かないこともあります。これでは、変更によって発生したエラーを追跡するのに数日を要してしまいます。わたしも最近、プロジェクトで、バックエンド・データベースをInformixからOracleに変更したときに同じことを経験しました。大半の部分で、変更は問題なく進みました。しかし、データベース層、つまりデータベース層を使用しているシステムで単体テストを行わなかったため、変更によるバグを解決するまで非常に時間がかかってしまいました。他の誰かが作成したコード内でデータベース構文の変更を見つけるのに2日もかかったわけです (もちろん、その人が今でもわたしの友人であることに変わりはありません)。
こうしたメリットがあるにもかかわらず、テストというものは、世のプログラマーを興奮させません。最初はわたしも心を惹かれませんでした。「コンパイルしたんだから、動くはずだ」というせりふを何度耳にしたでしょうか。「我思う、ゆえに我あり」の法則は、高品質のソフトウェアには通用しない のです。プログラマーにコード・テストを行う気持ちにさせるには、そのプロセスが単純で、簡単なものでなければなりません。
ここでは、Java言語のプログラミングを学習する際に作成する単純なクラスから始めます。このクラスの単体テストを作成する方法を紹介し、その後でbuildプロセスに単体テストを追加します。最後に、コードにバグが入り込むとどうなるかについて説明します。
一般的なクラスから始める
初めて作成する典型的なJavaプログラムには、"Hello World" を出力するmain() が入っています。リスト1では、HelloWorldオブジェクトのインスタンスを作成し、おなじみのせりふを出力するsayHello() メソッドを呼び出します。
リスト1. わたしの初めてのJava "Hello world" アプリケーション
/*
* HelloWorld.java
* My first java program
*/
class HelloWorld {
/**
* Print "Hello World"
*/
void sayHello() {
System.out.println("Hello World");
}
/**
* Test
*/
public static void main( String[] args ) {
HelloWorld world = new HelloWorld();
world.sayHello();
}
}
|
main() メソッドがわたしのテストです。成功です。1つのモジュールに、コード、文書、テスト、サンプル・コードをすべて挿入しました。Javaに感謝しましょう。ただし、プログラムが拡張すると、この開発方法はあっという間に欠点を見せ始めました。
- クラッター
クラス・インターフェースが拡大すると、main() も拡大します。クラスは、テストを正しく行うという理由だけのために巨大化する可能性があります。
- コードの膨張
製品コードは、テストのために必要以上に大きくなります。しかし、提供したいのは、テストではなく製品だけです。
- テストは信頼できない
main() はクラスの一部であるため、main() は、他の開発者がクラス・インターフェースを介してアクセスできない、プライベートなメンバーやメソッドにアクセスすることができます。このため、このテスト方法ではエラーが発生しやすくなります。
- テストの自動化は難しい
自動化するためには、引数でmain() に渡す2つ目のプログラムを作成しなければなりません。
クラスの開発
個人的には、クラスの開発はmain() メソッドをコーディングすることから始めました。main() を作成したときのように、クラスとクラスの使用を定義してから、インターフェースをインプリメントします。この場合にも、明らかな欠点が見え始めました。1つ目は、テストを実行するためにmain() に渡す引数の数でした。2つ目は、main() そのものが、サブメソッド呼び出し、セットアップ・コードなどでクラッター化してきたことでした。main() が、クラス・インプリメンテーションの他の部分よりも大きくなったということが理由でした。
もっと簡単なプロセス
オリジナルのアプローチでは、欠点が簡単に見つかってしまいました。では、もっと楽な代替プロセスを紹介しましょう。インターフェースを介してコードを設計し、使用例を示すまでは、オリジナルのmain() の場合と同じです。違うのは、コードをそれぞれ別々のクラスに入れる点です。この別 々のクラスも「単体テスト」になります。このテクニックには、いくつかのメリットがあります。
- クラスを設計する場合のメカニズム
わたしは、インターフェースを介して開発を行っているので、内部クラスの機能は利用できそうもありません。しかし、ターゲット・クラスの開発者であるため、内部作業に入る機会はあります。したがって、テストはわたしにとって本当の意味での未知の世界ではありません。この点だけで、テストの開発を担当するのは他でもなくターゲット・クラスの作成を担当する開発者でなければならない、ということが言えます。
- クラス使用の例
例とインプリメンテーションとを切り離すことで、開発期間を一層短縮することができます。ソース・コードをこれ以上変更してはなりません。この切り離しにより、開発者が、将来的にはそこにないかもしれない内部クラス機能を利用する恐れがなくなります。
- クラス・クラッターのない
main()
もはやmain() で制限されていません。以前は複数のパラメーターをmain() に渡して、各種の構成をテストしていました。今では、別個のテスト・クラスを作成して、個別 に個々のセットアップ・コードを更新することができます。
この別個の単体テスト・オブジェクトをbuildプロセスに取り込むことで、一歩先のステップに進むことができます。こうすることで、検証プロセスを自動化する方法を提供できるのです。
- 変更が、他の開発者に悪影響を与えないことを検証します。
- アセンブリー・テストや夜間のbuildテストまで待たずに、ソース制御をチェックする前にコードをテストすることができます。これにより、プロセスの早期段階でバグを見つけることができ、低コストで質の高いコードを生成することができます。
- 増分テスト・プロセスを提供することで、優れたインプリメンテーション・プロセスを提供します。IDEが、タイプ入力時での構文バグやコンパイル・バグの発見に役立つように、増分単体テストは、build時でのコード変更バグの発見に役立ちます。
JUnitを利用した単体テストの自動化
テストを自動化するには、テスト・フレームワークが必要です。独自のフレームワークを開発したり、購入したり、JUnitのようなオープン・ソース・ツールを使用したりすることができます。わたしがJUnitを気に入っているのは、次のような理由によります。
- 独自のフレームワークを作成する必要がありません。
- オープン・ソースであるので、フレームワークを購入するお金がかかりません。
- オープン・ソース・コミュニティーの他の開発者も使用しているので、多くの例を見つけることができます。
- テスト・コードと製品コードを切り離すことができます。
- buildプロセスに簡単に統合できます。
テストのレイアウト
図1に、サンプルTestSuiteを用いたJUnit TestSuiteレイアウトを示します。各テストは、個々のテスト・ケースから成り立ちます。各テスト・ケースは、TestClassを拡張し、テスト・コード、つまりmain() に存在したコードを含む、個々のクラスです。この例では、すべての新規クラスとHelloWorldクラスの開始点として使用する TestSuite: a SkeletonTestに2つのテストを追加しました。
図1. TestSuiteのレイアウト
テスト・クラスHelloWorldTest.java
規約により、テスト・クラスの名前は、テストしているクラスと同じ名前の末尾に、Test が付加された名前となります。この場合、テスト・クラスの名前はHelloWorldTest.java です。SkeletonTestからコードをコピーして、testSayHello() を追加し、sayHello() をテストしました。HelloWorldTestはTestCaseを拡張したものであることに注意してください。JUnitフレームワークには、検証に使用できるassert メソッドとassertEquals メソッドが用意されています。リスト2に、HelloWorldTest.java を示します。
リスト2. HelloWorldTest.java
package test.com.company;
import com.company.HelloWorld;
import junit.framework.TestCase;
import junit.framework.AssertionFailedError;
/**
* JUnit 3.2 testcases for HelloWorld
*/
public class HelloWorldTest extends TestCase {
public HelloWorldTest(String name) {
super(name);
}
public static void main(String args[]) {
junit.textui.TestRunner.run(HelloWorldTest.class);
}
public void testSayHello() {
HelloWorld world = new HelloWorld();
assert( world!=null );
assertEquals("Hello World", world.sayHello() );
}
}
|
testSayHello() は、HelloWorld.java における オリジナルのmainメソッドと似ていますが、大きな違いが1つあります。System.out.println を実行して、結果をビジュアルで確認する代わりに、assertEquals() メソッドを追加しました。2つの値が異なった場合、assertEquals は両方の入力値を出力することになっているのですが、これが機能しないことに気付いたでしょうか。HelloWorld内のsayHello() メソッドはストリングを戻しません。最初にテストを作成していれば、このことに気付いたはずです。わたしは、"Hello World" ストリングを出力ストリームに結合していたのです。そのため、リスト3に示すようにクラスHelloWorldを作成し直して、main() を除去し、sayHello() の戻り型を変更します。
リスト3. Hello worldのテスト・クラス
package com.company;
public class HelloWorld {
public String sayHello() {
return "Hello World";
}
}
|
main() を残して結合を修正すると、以下のようになります。
public static void main( String[] args ) {
HelloWorld world = new HelloWorld();
System.out.println(world.sayHello());
}
|
新しいmain() は、テスト・プログラムのtestSayHello() と非常によく似ています。もちろん、これは現実の世界のものではありません (単なる例です) が、要点は抑えています。別個のアプリケーションでmain() を作成すれば、コード設計が改善されるだけでなく、テストを設計する場合にも便利です。テスト・クラスを作成し終えたので、次にAntを使用してbuildにこのクラスを統合しましょう。
Antを使用してbuildにテストを統合する
Jakarta Projectは、Antツールのことを「makeの欠点を取り除いたmake」と呼んでいます。Antは、オープン・ソース世界の事実上の標準になりつつあります。その理由は簡単です。Antは、Java言語で作成されているので、buildプロセスを複数のプラットフォームで動作させることができるのです。この特徴により、オープン・ソース・コミュニティーの必要条件である、異なるOSプラットフォームで開発を行っているプログラマー同士間の協力が簡単になります。お手持ちのプラットフォームで開発および buildすることができます。Antの特徴は次のとおりです。
- クラスの拡張性
シェル・ベースのコマンドの代わりに、Javaクラスを使用してbuild機能を拡張します。
- オープン・ソース
Antはオープン・ソースなので、クラス拡張の例はたくさんあります。例を取り上げて学習することはすばらしいことだと思います。
- XMLで構成可能
Antは、単にJavaベースというだけではありません。Antは、buildプロセスの構成にXMLファイルを使用します。buildが本来、階層的なものであるとするなら、XMLを使用してmakeプロセスを記述することは論理的です。また、XMLを知っていれば、buildの構成方法ももっと簡単に学習することができます。
図2に、構成ファイルの概要を示します。構成ファイルは、1つのターゲット・ツリーから成り立ちます。各ターゲットには、実行されるタスクが含まれます。タスクとは、実行できるコードのことです。例で、mkdir は、ターゲットcompile のタスクです。mkdir は、ディレクトリーを作成するAntに組み込まれたタスクです。Antには、組み込みタスクの大きなリストが添付されています。また、Antタスク・クラスを拡張することで独自の機能を追加することもできます。
各ターゲットには、一意の名前とオプションの依存関係が割り当てられています。ターゲットの依存関係は、ターゲット独自のタスク・リストを実行する前に実行する必要があります。図2では、JUNITターゲットを、コンパイル・ターゲット内のタスクを実行する前に実行する必要があります。このタイプの構成では、1つの構成内に複数のツリーを入れることができます。
図2. Ant XML buildダイヤグラム
従来のmakeユーティリティーと似ていることがよく分かります。makeはmakeであるので、これは当たり前のことです。しかし、クロス・プラットフォーム、Javaを介して拡張が可能、XMLを介して構成が可能、オープン・ソースである、という相違点があることを覚えておきましょう。
Antをダウンロードしてインストールする
まず、Antのダウンロードから始めましょう (参考文献 を参照)。Antをtoolsディレクトリーに解凍します。Antbin ディレクトリーをパスに追加します (わたしのマシンでは、e:\tools\ant\bin でした)。ANT_HOME環境変数を設定します。NTの場合は、システム・プロパティーで、ANT_HOMEを値を持つ変数として追加します。ANT_HOMEは、Antのルート・ディレクトリー、つまりbin ディレクトリーとlib ディレクトリーが入ったディレクトリーに設定しなければなりません (わたしの場合は、e:\tools\ant でした)。JAVA_HOME環境変数が、JDKがインストールされているディレクトリーに設定されていることを確認します。インストールの詳細については、Antの文書を参照してください。
JUnitをダウンロードしてインストールする
JUnit 3.2をダウンロードします (参考文献 を参照)。
junit.zip を解凍して、CLASSPATHにjunit.jar を追加します。junit.zip をclasspathパスに解凍した場合は、次のコマンドを実行することでインストールをテストできます。
java junit.textui.TestRunner junit.samples.AllTests
ディレクトリー構造を定義する
buildプロセスとテスト・プロセスを始める場合は、プロジェクト・レイアウトが必要です。図3に、サンプル・プロジェクトのレイアウトを表示します。次に、レイアウトのディレクトリー構造について説明します。
-
build -- クラス・ファイルの一時的なbuild場所。このディレクトリーを作成するのは、buildです。
-
src -- ソース・コードの場所。Src は、すべてのテスト・コードを収めたtest フォルダーと、配布可能コードを収めたmain フォルダーに分割されます。テスト・コードとメイン・コードの切り離しには、いくつかのメリットがあります。1つ目に、メイン・コードでのクラッター化を抑えることができます。2つ目に、パッケージと位置を合わせることができます。クラスと、クラスを関連付けるパッケージの位 置を合わせることには大賛成です。テストは、テスト同士位置を合わせます。顧客に単体テストを配布する必要はまずないので、これは配布プロセスにも役立ちます。
実際には、distribution やdocumentation など、ディレクトリーはもっとたくさんあるでしょう。また、main の下に、com.company.util などのパッケージ用のディレクトリーがあるかもしれません。
ディレクトリー構造は変化が激しいので、build.xml にこれらの変更のためのグローバル・ストリング定数があることが重要です。
図3. プロジェクトのレイアウト図
サンプルAnt build構成ファイル
次に、構成ファイルを作成します。リスト4に、サンプルのAnt buildファイルを示します。このbuildファイルのキー・ポイントは、runtestsという名前のターゲットです。このターゲットは、外部プログラムをforkして、実行します。外部プログラムは、事前にインストール済みのjunit.textui.TestRunner です。ステートメントtest.com.company.AllJUnitTests で、どのテスト・セットを実行するかを指定します。
リスト4. サンプルbuildファイル
<project name="Sample.Project" default="runtests" basedir=".">
<property name="app.name" value="sample" />
<property name="build.dir" value="build/classes" />
<target name="JUNIT">
<available property="junit.present" classname="junit.framework.TestCase" />
</target>
<target name="compile" depends="JUNIT">
<mkdir dir="${build.dir}"/>
<javac srcdir="src/main/" destdir="${build.dir}" >
<include name="**/*.java"/>
</javac>
</target>
<target name="jar" depends="compile">
<mkdir dir="build/lib"/>
<jar jarfile="build/lib/${app.name}.jar"
basedir="${build.dir}" includes="com/**"/>
</target>
<target name="compiletests" depends="jar">
<mkdir dir="build/testcases"/>
<javac srcdir="src/test" destdir="build/testcases">
<classpath>
<pathelement location="build/lib/${app.name}.jar" />
<pathelement path="" />
</classpath>
<include name="**/*.java"/>
</javac>
</target>
<target name="runtests" depends="compiletests" if="junit.present">
<java fork="yes" classname="junit.textui.TestRunner" taskname="junit" failonerror="true">
<arg value="test.com.company.AllJUnitTests"/>
<classpath>
<pathelement location="build/lib/${app.name}.jar" />
<pathelement location="build/testcases" />
<pathelement path="" />
<pathelement path="${java.class.path}" />
</classpath>
</java>
</target>
</project>
|
サンプルAnt buildを実行する
開発プロセスの次のステップは、HelloWorldクラスを作成してテストするbuildを実行することです。リスト5に、このbuildの結果を示します。各ターゲット・セクションを示します。大切な部分は、runtests出力ステートメントです。このステートメントで、テスト・セット全体が正常に実行されたことが分かります。
図4および図5に、JUnitのGUIを示します。ここで必要なことは、runtestターゲットをjunit.textui.TestRunner からjunit.ui.TestRunner に変更しなければならないことだけです。JUnitのGUI部分を使用する場合には、「終了」ボタンを選択して、buildプロセスを継続しなければなりません。JUnitのGUIを使用すると、パッケージのbuildとさらに大きいbuildプロセスとの統合が困難になります。また、テキスト出力とbuildプロセスとの整合性が向上し、テキスト出力をマスターbuildレコード用のテキスト・ファイルにパイピングすることができます。これは、夜間のbuildに便利です。
リスト5. サンプルbuild出力
E:projectssample>ant runtests
Searching for build.xml ...
Buildfile: E:projectssamplebuild.xml
JUNIT:
compile:
[mkdir] Created dir: E:projectssamplebuildclasses
[javac] Compiling 1 source file to E:projectssamplebuildclasses
jar:
[mkdir] Created dir: E:projectssamplebuildlib
[jar] Building jar: E:projectssamplebuildlibsample.jar
compiletests:
[mkdir] Created dir: E:projectssamplebuildtestcases
[javac] Compiling 3 source files to E:projectssamplebuildtestcases
runtests:
[junit] ..
[junit] Time: 0.031
[junit]
[junit] OK (2 tests)
[junit]
BUILD SUCCESSFUL
Total time: 1 second
|
図4. JUnit GUIでのテストの成功
図5. JUnit GUIでのテストの失敗
テストのしくみ
少し中断して、何が起こっているかを見てみましょう。夜間なので、"Hello World" を静的ストリングにすることにします。変更の際に、変更にfat finger を実行して、リスト6に示すように "o" を "0" にします。
リスト6. Hello worldクラスの変更
package com.company;
public class HelloWorld {
private final static String HELLO_WORLD = "Hell0 World";
public String sayHello() {
return HELLO_WORLD;
}
}
|
パッケージをbuildしていると、途中でエラーが表示されます。リスト7に、runtestのエラーを示します。異常終了したテスト・クラスとテスト・メソッド、およびその理由が表示されます。コードに戻り、バグを修正してから、先に進みます。
リスト7. サンプルbuildエラー
E:projectssample>ant runtests
Searching for build.xml ...
Buildfile: E:projectssamplebuild.xml
JUNIT:
compile:
jar:
compiletests:
runtests:
[junit] ..F
[junit] Time: 0
[junit]
[junit] FAILURES!!!
[junit] Test Results:
[junit] Run: 2 Failures: 1 Errors: 0
[junit] There was 1 failure:
[junit] 1) testSayHello(test.com.company.HelloWorldTest) "expected:<Hello World> but was:<Hell0 World>"
[junit]
BUILD FAILED
E:projectssamplebuild.xml:35: Java returned: -1
Total time: 0 seconds
|
まったく苦労しないわけではない
新しいプロセスは、まったく苦労を伴わないというわけではありません。開発の単体テスト部分を行う場合に取らなければならないステップを次に示します。
- JUnitをダウンロードして、インストールします。
- Antをダウンロードして、インストールします。
- build用の別個の構造を作成します。
- メイン・クラスとは別個のテスト・クラスをインプリメントします。
- Ant buildプロセスを覚えます。
しかし、苦労よりもメリットの方が勝ります。開発プロセスの単体テスト部分を作成すれば、次のことを実行できます。
- 変更のバグを見つける検査を自動化できます。
- インターフェースからクラスを設計できます。
- わかりやすい例が用意されています。
- リリース・パッケージでコードのクラッターやクラスの膨張を避けることができます。
24時間365日稼働を目指す
製品の品質を保証することにはお金がかかりますが、品質が低ければさらにお金がかかります。どうしたら、製品の品質を保証するには何をすればよいでしょうか。
- 設計とコードを見直します。テストだけで達成できたことの半分についてのコストを見直します。
- 単体テストによるモジュール作業を確認します。
テストは常にありますが、開発過程が発展するにつれて、単体テストは毎日の開発テストの一部になります。
過去10年のわたしの開発の中で、emageon.comの仕事は、最高の出来の1つです。emageon.comでは、設計の見直し、コードの見直し、そして単体テストが毎日の日課でした。毎日の習慣にすることで、最高品質の製品が出来上がるのです。顧客サイトでの初年度のソフトウェアのダウン時間はゼロで、本当の意味で24時間365日稼働製品になりました。単体テストは、歯磨きのようなものです。毎日行わなければならないものではありませんが、毎日行えば、生活の質は非常に向上するのです。
参考文献
著者について  | |  | Malcolm G. Davis氏は、アラバマ州、バーミンガムでコンサルティング会社を経営しています。自分のことをJava伝道者だと思っています。
Javaの価値を説教していないときは、ランニングしたり、子供たちと遊ぶことに時間を使っています。連絡先はmalcolm@nuearth.com です。 |
記事の評価
|