ガイドライン: 自動化されたテスト スイートの保守
トピック
物理対象の場合と同様に、テストが機能しなくなる場合があります。これは、テストが使用に耐えられなくなったのではなく、環境の中で何かが変化したことによるものです。可能性がある原因は、新しいオペレーティング システムに移植されたことや、実行していたコードをテストが失敗するように変更していることです。たとえば、電子バンキング アプリケーションのバージョン 2.0 を操作している場合を考えます。バージョン 1.0 では、ログインに次のメソッドが使用されていました。
public boolean login (String username);
バージョン 2.0 では、マーケティング部によってパスワード保護のアイデアが導入されます。この結果、メソッドは次のように変更されます。
public boolean login (String username, String password);
login を使用するテストはすべて失敗します。コンパイルも完了しません。ログインしなければ有効な操作のほとんどは動作しないため、login を使用しない有効なテストはほとんど記述できません。テストの失敗が大量に生成されることになります。
これらのテストは、グローバル検索と置換ツールを使用して修正できます。login(something) の各インスタンスを検索して、login(something, "dummy password") で置換します。続いて、パスワードを使用するようにすべてのテスト用アカウントを変更します。
後で、マーケティングがパスワードに空白を使用しないことを決定した場合は、もう一度この操作をやり直します。
特にテストの変更が難しい場合、このような処理は時間の無駄になります (ほとんどの場合は、変更が困難です)。より適切な方法を考えます。
テストが、初めから製品の login メソッドを呼び出していなかった場合を考えます。この場合、テスト用にログインし、テストの続行を準備するために必要な処理を行う、ライブラリ メソッドを呼び出すことになります。初期の段階では、このメソッドは次のようになります。
public boolean testLogin (String username) {
return product.login(username);
}
バージョン 2.0 への変更が行われると、ユーティリティ ライブラリは次のように変更されます。
public Boolean testLogin (String username) {
return product.login(username, "dummy password");
}
無数のテストを変更する代わりに、1 つのメソッドを変更するだけですみます。
必要なライブラリ メソッドをすべて、テスト作業の最初から利用できるようにするのが理想的です。ただし実際には、製品の login が変更されるまで、testLogin ユーティリティ メソッドが必要となることは予期されませんしたがって、テスト ユーティリティ メソッドは多くの場合、既存のテストから必要に応じて作成されます。スケジュールが差し迫った場合でも、進行中のテストを修正することは非常に重要です。修正を行わなければ、不格好で保守不可能なテスト スイートのために多くの時間を無駄にすることになります。テストの破棄を考えなければならない場合もあります。そうでなければ、過去のテストの保守に時間をすべて費やして、必要なだけの新しいテストを作成できなくなります。
メモ: 製品の login メソッドのテストでは、今までどおり直接メソッドを呼び出します。メソッドの振る舞いが変わった場合は、テストの一部またはすべてを更新する必要があります。振る舞いが変更されたにも関わらず、login のテストがすべて成功する場合は、障害の検出について不備があると思われます。
前に示した例では、具体的なアプリケーションからテストをどのように抽象化するかを説明しました。多くの場合、さらに抽象化を行うことができます。多くのテストは、共通した一連のメソッド呼び出しで開始されていることがわかります。すなわち、ログイン、状態の設定、アプリケーションのテスト部分への移動です。各テストで異なるのは、これらの一連の呼び出しの後だけです。この設定部分をすべて 1 つのメソッドに抽象化して、readyAccountForWireTransfer などのわかりやすい名前を付けます。これにより、特定の種類の新しいテストを作成するときに、時間を大きく短縮でき、各テストの意図をよりわかりやすくすることもできます。
テストがわかりやすいことは重要です。古いテスト スイートで共通の問題は、テストが何を行っているか、またはその理由が誰にもわからないことです。これらのテストが機能しなくなった場合、できるだけ簡単な方法でテストを修正しようとする傾向があります。この方法では、障害を検出するテストの機能が弱くなります。最初にテストする予定だった内容がテストされなくなります。
コンパイラをテストする場合を考えます。記述された最初のクラスのいくつかは、コンパイラの内部的な解析木と、それに基づいて行われる変換を定義します。解析木を構成する多数のテストを作成して、変換をテストします。これらのテストは、たとえば次のようになります。
/*
* 次の場合、
* while (i<0) { f(a+i); i++;}
* "a+i" は、ループから移動できません。
* ループ中で変更される変数が含まれています。
*/
loopTest = new LessOp(new Token("i"), new Token("0"));
aPlusI = new PlusOp(new Token("a"), new Token("i"));
statement1 = new Statement(new Funcall(new Token("f"), aPlusI));
statement2 = new Statement(new PostIncr(new Token("i"));
loop = new While(loopTest, new Block(statement1, statement2));
expect(false, loop.canHoist(aPlusI))
これは読みにくいテストです。後で見直しが必要な場合を考えます。何らかの変更によって、テストの更新が必要となります。この時点で、多くの基礎構造ができています。特に、文字列を解析木に変換する解析ルーチンが完成しているとします。ここで、解析ルーチンを使用するテストを、完全に作成し直しことにします。
loop=Parser.parse("while (i<0) { f(a+i); i++; }");
// ループの "a+i" の部分へのポインタを取得
aPlusI = loop.body.statements[0].args[0];
expect(false, loop.canHoist(aPlusI));
これらのテストは理解しやすく、現在も、将来的にも時間の節約になります。実際に、テストの保守にかかる費用は大きく削減されるので、パーサが利用できるようになるまで、これらの多くを後回しにすることは妥当です。
この方法には、わずかな不利益もあります。これらのテストが、変換コードやパーサで障害を発見する可能性があります。変換コードの場合は意図したとおりですが、パーサで障害が発見されるのは偶然です。したがって、問題の分離とデバッギングがいくらか難しくなる可能性があります。ただし、パーサのテストで見落とされた問題が発見されることは、悪いことではありません。
パーサの障害が変換コードの障害を隠している場合もあります。この可能性は小さく、これに関わる費用は、より複雑なテストの保守にかかる費用に比べるとごくわずかです。
大規模なテスト スイートには、変更されることのないテスト ブロックがあります。これらは、アプリケーションの安定した領域に対応します。テストのほかのブロックは、頻繁に変更されます。これらは、アプリケーションの振る舞いが頻繁に変わる部分に対応しています。これらの頻繁に変更されるブロックでは、ユーティリティ ライブラリを多用する傾向があります。各テストでは、不安定な領域の特定の振る舞いをテストします。ユーティリティ ライブラリは、テスト対象外の振る舞いの変更から影響を受けないようにして、テスト目標の振る舞いを確認できるように設計されています。
たとえば、前に示した「ループの移動」のテストは、現在のところ、解析木をどのように作成するかには影響を受けません。ただし、while ループの解析木の構造には影響を受けます (a+i のサブツリーのフェッチに必要なアクセス順序のため)。構造が不安定であることがわかれば、fetchSubtree ユーティリティ メソッドを作成して、テストをより抽象化できます。
loop=Parser.parse("while (i<0) { f(a+i); i++; }");
aPlusI = fetchSubtree(loop, "a+i");
expect(false, loop.canHoist(aPlusI));
これで、テストが影響を受けやすいのは、言語の定義 (たとえば、++ によって整数が 1 増加するなど) と、ループの移動に関する規則の 2 つだけです。
ユーティリティ ライブラリを使用しても、テストとまったく関係のない振る舞いの変更により、テストは定期的に機能しなくなります。テストを修正しても、変更による障害を検出できる見込みはあまりありません。テストの修正は、テストがほかの障害を検出する機会を残しておくための作業にすぎません。一連の修正にかかるコストは、テストによって障害が発見されるという価値を上回ります。そのテストを破棄して、より価値の高い新しいテストを作成することに労力を傾ける方が適切な場合もあります。
最初は、ほとんどの開発者はテストを破棄するという意見に反対しますが、最終的には、保守にかかる負担に圧倒され、すべてのテストを破棄してしまいます。テストごとに、注意深く、継続的に次の質問について考慮してください。
- このテストの修正 (おそらくは、ユーティリティ ライブラリへの追加) に、どれだけの作業が必要ですか。
- ほかにどれくらいの時間を使いますか。
- そのテストは、今後、深刻な障害をどれくらい検出しそうですか。そのテストと関連するテストに、これまでどの程度の実績がありますか。
- テストが再び機能しなくなるまでに、どれくらい稼働しますか。
これらの質問に対する答えは、大まかな見積もりまたは推測になります。ただし、これらの質問について考えることは、すべてのテストを修正するという方針よりも、よい結果をもたらします。
テストを破棄する別の理由は、それが冗長になっているということです。たとえば、開発初期の段階では、基本的な解析木の構文メソッドに対する簡単なテスト (LessOp など) が多数あります。その後、パーサの作成時には、多数のパーサ テストが存在します。パーサは構文メソッドを使用するため、これらのメソッドも間接的にパーサ テストでテストされます。コードの変更によって構文のテストが機能しなくなった場合、冗長なテストは破棄するのが妥当です。もちろん、新しい構文または変更された構文の振る舞いには、新しいテストが必要です。これらのテストは、パーサを通じて完全にテストするのが困難な場合は直接、パーサを通したほうが適切で管理しやすい場合は間接的に実装されます。
|