トピック

概論ページの先頭へ

「開発者テスト」とは、ソフトウェア開発者が最も適切な形で実施するテスト作業を指します。開発者テストには、テスト作業に付随する成果物が含まれます。また従来、開発者テストは、単体テスト、統合テストのかなりの部分、通常システム テストなどと呼ばれるテストの一部に分類されてきました。これまで開発者テストは実装作業分野の作業と結び付けられてきましたが、このテストは分析と設計作業分野の作業にも関係しています。

このように「全体論的に」開発者テストを考えることは、従来取られてきた「原子論的方法」のリスクを低減する効果を生みます。従来の開発者テストでは、作業の当初は、個々のユニットが独立して動作するかどうか確認することに焦点を置きます。そして開発作業も完了間近となった開発ライフサイクルの終盤に、統合されたユニットを組み合わせて有効なサブシステムやシステムを完成させてから、この状態での初のテストを実施します。

このような方法には多くの欠点があります。第 1 に、ユニットを統合し、サブシステムとして組み立ててからテストを行うことで、テスト時に障害が発見された時には遅すぎるという事態に陥りやすくなります。このように遅すぎるタイミングで障害が検出された場合には、多くのケースで、是正措置を取らなかったり、大掛かりな是正作業をせざるを得ないという事態になります。この種の是正作業は、高コストなだけでなく、他の分野の進捗を阻むものともなり、それによりプロジェクト自体がレールから外れ中止されるリスクが増します。

第 2 に、ユニット、統合、システムの各テスト間に強固な境界線を設けることで、境界線上にある障害を発見できないという事態を招くことが考えられます。各テストを別々のチームが担当する場合には、そのリスクはさらに大きくなります。

RUP では、開発者が所定のタイミングに重要かつ適切なテストに集中するという開発者テストのスタイルを推奨しています。1 つの反復の中でも、開発者にとっては、余計なコストをかけて別のテスト グループに引き継ぐよりも、自力で見つけられる限り多くの障害を検出し是正する方がはるかに効率的です。ここでは、(独立したユニット、統合されたユニット、重要なエンド ユーザー シナリオの中で実際に稼働している統合ユニットのいずれの場合でも) 特に重要なソフトウェア障害を早期に発見することが重要になります。

開発者テスト スタート時の落とし穴 ページの先頭へ

徹底したテストを行おうとする開発者の多くが、開始後間もなく、その努力を放棄しています。それは、テストが価値生み出すように思えなくなるからです。また、開発者テストを好調にスタートさせた場合でも、すべては実施しきれず最終的には切り捨てなければならないことがあります。

ここでは、最終的に実施しきれないという罠にはまらないテストセットを作成し、開発者テストの最初のハードルをクリアするためのガイドラインを示します。詳しくは、「Guidelines: Maintaining Automated Test Suites」を参照してください。

予測の設定 ページの先頭へ

開発者テストをメリットのあるものだと考える開発者はそれを行い、開発者テストを面倒な作業だと考える開発者はそれを避けようとします。これは、ほとんどの業界のほとんどの開発者に普通に見られる傾向であり、開発者テストが、その作業分野を満たさないものと考えることは、歴史的にも間違いであることがわかっています。このことから、開発者は、テストがメリットをもたらすものであると期待すべきであり、メリットをもたらすようにテストを行う必要があります。

理想的な開発者テストは、非常に厳密なエディット テスト ループに沿って実施されます。開発者は、クラスに新規のメソッドを追加するなど、製品に小さな変更を加え、それを直ちにテストに反映させます。テストが成功しなければ、どのコードに問題があるかすぐに分かるというわけです。このように開発を容易に堅実に勧めることができれば、それは開発者テストの最大のメリットと言えます。これにより長期にわたるデバッギング セッションを大幅に減らすことができます。

あるクラスで行われた変更が別のクラスに影響を及ぼすことは珍しくないので、変更されたクラスのテストだけでなく、多くのほかのテストの再実行を予測しておく必要があります。コンポーネントのテスト セット全体を 1 時間ごとに何度も繰り返すことが理想的です。大きな変更を実施した際には、その都度テスト セットを再実行して結果を観察し、それによって次の変更に進むか、直前の変更に改善を加えるか判断します。また、迅速なフィードバックを実現するだけの労力を予測する必要があります。

テストの自動化 ページの先頭へ

テストを手作業で繰り返していては非効率です。一部のコンポーネントは、簡単にテストを自動化することが可能です。例えば、メモリ内データベースなどがそれに当ります。メモリ内データベースは API を介してクライアントと通信しており、これ以外に外界とのインターフェイスを持っていません。メモリ内データベースのテストは、次のようになります。

/* Check that elements can be added at most once. */

// Setup
Database db = new Database();
db.add("key1", "value1");

// Test
boolean result = db.add("key1", "another value");
expect(result == false);

このテストが一般のクライアント コードと異なるのは、API のコールの結果をそのまま信じないで、自身で検証するという点のみです。API のクライアント コードの記述が容易なら、テスト コードの記述も容易です。もしテスト コードの記述が容易でないなら、それは、API に改善が必要だという早い段階での警告となります。以上のことからテスト先行設計は、大きなリスクに早期に対応するという点で Rational Unified Process の注力ポイントに沿っていると言えます。

しかし、外界と密に接続するコンポーネントの場合には、テストは難しくなります。このようなコンポーネントの例としては、グラフィカル ユーザー インターフェイスとバックエンド コンポーネントの 2 つが考えられます。

グラフィカル ユーザー インターフェイス

上記の例のデータベースが、ユーザー インターフェイス オブジェクトからのコール バックによってデータを受け取ると考えてください。このコール バックは、ユーザーがいくつかのテキスト フィールドに入力しボタンを押すと呼び出されます。このテストのために、手作業でフィールドに入力しボタンを押すという操作を 1 時間に何度も繰り返すのは大変です。そこでプログラムによる制御の下、一般にはコードの記述により「ボタンを押す」ことで、入力情報を伝達する方法を考えなければなりません。

ボタンが押されると、コンポーネントのいくつかのコードが実行されるようにします。また通常このコードは、いくつかのユーザー インターフェイス オブジェクトについて、その状態を変化させます。そこで、これらのオブジェクトに対してプログラムを使ってクエリー実行する方法も考える必要があります。

バックエンド コンポーネント

テスト中のコンポーネントが、データベースを実装しないと仮定します。その代わりに、コンポーネントは現実のディスク上のラッパーとなっています。この場合、実際のデータベースに対するテストの実行は難しく、インストールや構成も難しくなる可能性があります。さらに、そのためのライセンスもコストが高くなる場合があります。またデータベースは、しばしば実行するのがいやになるほどテスト速度を遅くするかもしれません。このような場合、テストに適した単純なコンポーネントを使うことで、このデータベースをスタブ アウトすることも有効です。

スタブは、対象となるコンポーネントの準備ができていない時にも有効です。こうすれば、自分のテストのために、ほかの人が作成したコードを待つ必要がありません。

詳細については、「概念: スタブ 」を参照してください。

独自ツールを作成しない ページの先頭へ

開発者テストは、非常に簡単なもののように見えます。いくつかのオブジェクトを準備し、API を通じて呼び出し、結果をチェックし、期待通りの結果が得られなければテストの失敗とすればよいのです。複数のテストを個別に、または完全なセットとして扱えるよう、テストをまとめる何らかの手段があれば便利です。そのための要求を満たすツールを、テスト フレームワークと呼びます。

開発者テストは簡単であり、テスト フレームワークの要求も複雑なものではありません。しかし、独自のフレームワークを作成したりすれば、その手間に予想以上の時間を要することになるでしょう。市販およびオープン ソースのものを合わせて、多くのテスト フレームワークが入手可能なので、これらを活用するのが賢明だと言えます。

サポート コードを作成する ページの先頭へ

テスト コードは繰り返しが多くなりがちで、一般に次のようなコードになります。

// null name not allowed
retval = o.createName(""); 
expect(retval == null);

// leading spaces not allowed
retval = o.createName(" l"); 
expect(retval == null);

// trailing spaces not allowed
retval = o.createName("name "); 
expect(retval == null);

// first character may not be numeric
retval = o.createName("5allpha"); 
expect(retval == null);

このコードでは、1 つのかたまりをコピー、ペーストして、さらに編集して別のかたまりを作成しています。

しかしこういった作成方法には、二重の危険が潜んでいます。もしインターフェイスに変化があれば、多くの編集作業が必要となります。(特に複雑なケースでは、単なるグローバルな置換では対応できないでしょう。) また、コードが非常に複雑な場合には、テストの意志を喪失するほどの膨大なテキストに悩まされます。

繰り返し作業を行う際には、サポート コード内では繰り返しをしないように十分に注意してください。上記のコードは単純な例ですが、下のように記述すれば、より読みやすく維持しやすいコードになります。

void expectNameRejected(MyClass o, String s) {
    Object retval = o.createName(s);
    expect(retval == null);
}
...

// null name not allowed
expectNameRejected(o, ""); 

// leading spaces not allowed.
expectNameRejected(o, " l"); 

// trailing spaces not allowed.
expectNameRejected(o, "name "); 

// first character may not be numeric.
expectNameRejected(o, "5alpha"); 

開発者は、テスト コードを記述する際に、コピー ペーストを多用しすぎるという過ちに陥ることが多々あります。思い当たる場合には、意識して他の方法に目を向けましょう。 コードのコピーは排除することを決心してください。

先にテストを作成する ページの先頭へ

コード作成の後にテストを作成しようとすると作業が面倒になるため、テストの作成がやっつけ仕事になりがちです。このため、コード作成の前にテストを作成しておくことで、テストを確実なフィードバック ループの一部に組み入れることができます。より多くのコードを実装する場合には、最終的にすべてのテストが完了するまで、多くのテストを経ることになります。先にテストを作成した場合の方が上手くいくことが多く、時間も短くて済みます。先にテストを作成する方法の詳細については、「概念: テスト ファースト設計」を参照してください。

テストを分かりやすい状態に保つ ページの先頭へ

テスト作成時には、後に自分自身、または他の誰かにより修正が必要になることを想定しておく必要があります。最も多い例は、後の反復によりコンポーネントの振る舞いに変更が必要となるケースです。簡単な例として、次のような平方根を宣言したコンポーネントを考えてみます。

double sqrt(double x);

このバージョンでは、負の引数では平方根が NaN (Binary Floating-Point Arithmetic に関する標準 IEEE 754-1985 より「数字ではない」 という意) を返すようになっています。そして新しい反復では、平方根は負数を受け入れ、次のように複雑な結果を返します。

Complex sqrt(double x);

このため、平方根を対象とした古いテストは、変更の必要があります。つまり、古いテストが何をするものであるかを理解した上で、新しい平方根に適するように更新する必要があるのです。テストを更新する時には、そのバグ検出の効果を損ねないよう十分な注意が必要です。この例としては、次のような例がしばしば見られます。

void testSQRT () {
	//  Update these tests for Complex 
	// when I have time -- bem
	/*
		double result = sqrt(0.0);
		...
	*/
}

また、さらに微妙な例もあります。実用に耐えるようにテストの変更を行ったのに、変更によって本来意図されていたテスト対象をテストできなくなったというものです。また反復が多すぎたために、最終的にテスト セットが減衰し、多くのバグを検出できなくなったという例もあります。この現象は、「テスト セットの減衰」とも呼ばれ、減衰したテスト セットは維持する意味がないので破棄されます。

そのテストがどのようなテスト構想を実装したものか理解していない限り、テストのバグ検出能力を維持することはできません。テスト コードには、製品コードに比べてその背後にある「理由」が分かりにくいにも関わらず、コメントが少なくなる傾向があります。

テスト セットの減衰は、間接的なテストに比べ、平方根の直接テストでは生じにくい現象です。平方根を呼び出すコードがあり、そのコードはテストを持ちます。平方根が変化すると、それらのテストの一部は失敗することになります。このため平方根に変更を加える人は、おそらくはテストにも変更を加えることになります。この場合、その人物がテストについて十分に理解がなく、変更とテストの関係がさほど明確になっていなければ、その過程でテストを減衰させてしまう可能性はより高くなります。

テストのためのサポート コード作成時には、そのコードを使用するテストの目的をはっきりと明確にするようなコードを作成するよう十分に注意してください。オブジェクト指向プログラムについて最も多い苦情は、ほかに何かをする余地が残されていないというものです。ほかに何かをするためには、その作業をどこか別の場所に送ってしまうという方法しかありません。このような構造にもメリットはありますが、新参者にはコードが理解しにくいという欠点があります。新参者が努力を惜しめば、変更作業が不適切なものになり、さらにはコードが複雑化、脆弱化する危険も高くなります。同じことは、テスト コードについても言えます (後の保守担当者が手を加える可能性が特に低い場合を除く)。このため、分かりやすいテスト コードを作成することで、問題の発生を防止することが大切になります。

製品の構成に適したテスト構成 ページの先頭へ

誰かが自分のコンポーネントを引き継いだと考えてください。引き継いだ人は、コンポーネントの一部に変更を加える必要に迫られています。その場合、引き継いだ人は古いテストを調査し、新しい設計に適合するかどうかチェックしようと考える可能性があります。そのためには、コード (テスト ファースト設計) の作成前に古いテストの更新が必要となります。

しかし、そこで適切なテストを見い出すことができなければ、このよい思いつきを達成することはできず、代りに変更を実行し、どのテストが失敗するかを確認して、それを是正するという作業を行うことになります。この作業は、テスト セットの減衰を招くでしょう。

以上の理由から、テスト セットを十分に構造化し、製品の構成からテストの場所を予測できるようにしておくことが重要になります。開発者の間では、1 つの階層内にテストをまとめ、1 つの製品クラスごとに 1 つのテスト クラスを対応させるという方法が一般的です。こうしておけば、誰かが Log という名前のクラスに変更を加えた場合、テスト クラスが TestLog と分かっていれば、ソース ファイルを探し出すことができるわけです。

テストでカプセル化を破壊する ページの先頭へ

テストを、クライアント コードと完全に同じように、クライアント コードと同じインターフェイスを用いたコンポーネントとの相互作用にのみ限定する場合があります。しかし、このようなテスト方法にはデメリットがあります。二重にリンクしたリストを持つ単純なクラスのテストを行うと仮定します。

図 1: 二重にリンクするリスト

テストの対象は DoublyLinkedList.insertBefore(Object existing, Object newObject) メソッドです。この中で、ある要素をリストの真中に挿入し、その成功を確認するというテストを行います。このテストでは、更新されたリストを作るために上のリストを使います。

図 2: 二重にリンクするリスト (項目の挿入後)

次は、リストの正当性をチェックするテストの例です。

// the list is now one longer. 
expect(list.size()==3);

// the new element is in the correct position
expect(list.get(1)==m);

// check that other elements are still there.
expect(list.get(0)==a);
expect(list.get(2)==z);

このテストは、十分なように見えますが、欠陥があります。例えばリストの実装が不適切な場合、逆方向ポインタは正しくセットされません。つまり、更新されたリストは下のようになります。

図 3: 二重にリンクするリスト (実装が不適切な場合)

DoublyLinkedList.get(int index) が最初から終りまでリストをくまなくトラバースすれば (この可能性は高い)、テストはこの失敗を見逃すことになるでしょう。クラスが elementBeforeelementAfter メソッドを提供する場合には、このような失敗を調べることは簡単です。

// Check that links were all updated
expect(list.elementAfter(a)==m);
expect(list.elementAfter(m)==z);
expect(list.elementBefore(z)==m); //this will fail
expect(list.elementBefore(m)==a);

しかし、これらのメソッドが提供されていない場合にはどうでしょうか。その場合には、疑わしい障害の存在がある場合には失敗するような、詳細なメソッド呼び出しを考えることになります。例えば、次のようなものが使用できます。

// Check whether back-link from Z is correct.
list.insertBefore(z, x);
// If it was incorrectly not updated, X will have 
// been inserted just after A.
expect(list.get(1)==m); 

ただし、この種のテストは作成に手間がかかる上、維持が非常に面倒になる可能性があります。(十分なコメントを付けておかないと、テストの必要性が非常に見えにくくなります。) この問題を解決するためには、次のような 2 つの方法があります。

  1. 公のインターフェイスに elementBeforeelementAfter メソッドを加える。ただし、この場合には実装がほかの人に露呈されるため、将来的な変更が困難になります。
  2. 「ボンネットの中を見る」ように詳細にテストを行い、1 つ 1 つチェックしていきましょう。

通常は、後に示した方法が適しています。これは、DoublyLinkedList のような単純なクラスにも当てはまり、また特に製品の中で最も複雑なクラスにも適しています。

一般に、テストはテスト対象のクラスと同じパッケージに入れることで 保護したり、フレンド アクセスを与えたりします。

テスト設計によくある誤りページの先頭へ

各テストでは、コンポーネントを実行し、そして正しい結果が得られるかどうかチェックします。テストの設計 (使用するインプットと、欠陥のなさをチェックする方法)が適切であればコンポーネントの欠陥を発見できますが、不適切であれば発見できません。テスト設計については、陥りやすい過ちがいくつかあります。

期待される結果を正しく指定していない ページの先頭へ

XML を HTML に変換するコンポーネントをテストするとします。まずサンプルとなる一部の XML を取り出して変換を実行し、その結果をブラウザで確認してみます。もし画面にきちんと表示されれば、それを期待される結果として保存することで、その HTML を「承認」します。その後、テストを行うことにより、実際の変換結果と期待される結果とを比較します。

このような方法には、危険が潜んでいます。熟練したコンピュータ ユーザーでさえ、コンピュータのすることに疑いを抱かない傾向があります。つまり、画面の表示では問題を見落としてしまう可能性が高いのです。(ブラウザが HTML の記述の誤りに特別に寛大であるという訳ではありません。) 先に問題のある HTML を期待される結果としてしまえば、テストでは絶対に問題を検出できません。

それよりは HTML を直接目でチェックする二重チェックの方が安全ですが、それでもまだ危険が残ります。出力が複雑なものであれば、問題は見落としやすくなります。この場合は、最初に期待される結果を手作業で記述しておけば、より多くの問題を検出できたはずです。

背景をチェックしていない ページの先頭へ

一般にテストでは、なされるべきことが期待通りになされたかどうかをチェックします。しかしテスト作成者は、往々にして残されるべきものが期待通りに残されているかどうかのチェックを忘れてしまいます。例えば、プログラムのファイルの最初の 100 レコードを変更しようとしているとします。この場合は、101 番目のコードに変更が加えられていないことを確認することが大事です。

この時、理論上は「背景」(すべてのファイル システム、メモリ、ネットワークを介して通信可能なすべてのもの)をチェックすることになりますが、実際にはすべてをチェックする余裕はありませんので、実際にどれをチェック対象とするか慎重に決定する必要があります。この選択は重要です。

永続性をチェックしていない ページの先頭へ

単にコンポーネントに変更を加えても、それがデータベース側で有効かどうかは分かりません。そこで、別のルートからデータベースをチェックする必要があります。

同じ値を使う ページの先頭へ

データベース レコードの 3 つのフィールドの有効性をチェックするテストがあるとします。このテストでは、問題のフィールド以外の他の多くのフィールドにも入力をしないと、テストを実行できません。こういったテストでは、ほかのフィールドにテスト担当者が同じ値を繰り返し使用することが多々あります。例えば、テキスト フィールドに自分の恋人の名前ばかり入力したり、数字フィールドには必ず 999 と入力するなどです。

問題は、時にそういったフィールドの中に、そのような入力をすべきでないものが含まれているというところにあります。これは、ありえない入力値の不明確な組み合わせにより生じるバグというものが往々にして存在するためです。もし常に同じ値を入力し続けるなら、こういったバグの検出の可能性はなくなります。この種のバグを検出するためには、その都度、入力値を変更するべきです。999 や恋人の名前以外の入力値を使ったとしても、通常は追加コストは発生しません。テストの入力値を変えることは、ほとんどコストがかからないどころか、潜在的にはメリットがあるのですから、是非変えてください。(メモ: 恋人が同じ職場にいる場合、入力値を変えたいからと前の恋人の名前を入力することは止めましょう。)

この方法には、ほかにもメリットがあります。例えば、フィールド Y を使用すべき場面でフィールド X を使用してしまうという障害があります。この時、いずれのフィールドにも「Dawn」と入力していたら、この障害は検出できません。

現実的なデータを使用していない ページの先頭へ

普通、テストでは架空のデータを使用します。よく使用されるのは、非現実的で単純な、顧客名を「ミッキー」、「スヌーピー」、「ドナルド」などとするものです。このデータは、極端に短いという点から見ても、現実のユーザーが入力する値とは異なりますから、現実の顧客名の入力により検出されるはずの欠陥が見落とされる可能性があります。例えば、こういった 1 語のみの名前では、スペース入りの名前をコードが処理していなくても、それを検出することができません。

わずかな労力を惜しまず、慎重に現実的なデータを使用しましょう。

コードが何もしないことに気づかない ページの先頭へ

データベース レコードを初期化してゼロにしようとしています。結果がゼロになる計算を実行して、それをレコードに保存させ、最後にレコードがゼロになっていることを確認します。このテストでは何が証明されたでしょうか。計算はまったく実行されていないのかもしれません。何も保存されていなくても、テストでは分かりません。

このような例は起りそうもないように思えます。しかし、同種のミスは、より微妙な状況で突発的に発生します。例えば、複雑なインストーラ プログラム向けのテストを作成する場合を考えます。テストでは、インストールの完了後に一時ファイルがすべて削除されたかどうかを確認します。しかし、インストーラのオプションのため、そのテストでは一時ファイルが 1 つ作成されませんでした。当然、プログラムは存在しないファイルの削除はしません。

コードが誤ったことに気付かない ページの先頭へ

プログラムでは、誤ったプロセスから正しい結果が得られることがあります。例えば簡単な例として、次のようなコードがあります。

if (a < b && c) 
    return 2 * x;
else
    return x * x;

この式にはロジカルな誤りがあります。あなたは、この式が誤った評価をしたら、誤ったブランチを取るというテストを作成しました。しかし偶然にも、テストの変数 X の値は 2 でした。このため誤ったブランチは偶然的に正しいものとなりました、つまり正しいブランチによる結果と偶然にも同じ結果が得られたのです。

期待通りの結果ごとに、誤ったプロセスからもその結果が得られる可能性がないかをチェックするべきです。この種のチェックは不可能であることもありますが、可能である場合もあります。



Rational Unified Process   2003.06.15