独立
各テストメソッドは、他のテストメソッドから影響を受けてはいけません。
テストとは、対象のコンポーネントの仕様そのものと言えます。
仕様が変更された場合は、対象のコンポーネントに変更が行われ、テストケースに対してもを追加・変更・削除が発生します。この時、仕様に変更がない部分のテストは、コンポーネント修正前も修正後も変わらず正しい仕様を表しているはずなので、当然デグレードが発生していない限りテストは成功しなければなりません。そうでなければ、回帰テストとして実行してエラーとなった場合に、対象のコンポーネントが悪いのか、テストが悪いのかわからなくなります。
JUnitはテストメソッド間の独立を促進するように設計されています。
各テストメソッドは、それぞれ別のインスタンスで実行されます。
そのため、テストケースのインスタンスフィールドは複数のテストメソッドで共有されることはなく、別のテストメソッドの結果に影響は受けません。
しかし、テストメソッド間に依存関係が生まれてしまう可能性はあります。
その原因となる可能性が高いのが次の2点です。
- 対象のコンポーネントが、外部環境(DBなど)に依存する場合
- テストケース内でstaticフィールドを使用している場合
例として、簡単な在庫クラスのJUnitテストを作ります。
/**
* 在庫クラス
*/
public class Inventory {
/** 現在庫数 */
private int qunatity;
/**
* コンストラクタ
* @param quantity
*/
public Inventory(int quantity){
this.qunatity = quantity;
}
/**
* 現在庫数を取得する
* @return
*/
public int getQuantity(){
return this.qunatity;
}
/**
* 在庫を増やす
* @param quantity
*/
public void increaseQuantity(int quantity){
this.qunatity += quantity;
}
/**
* 在庫を減らす
* @param quantity
*/
public void decreaseQuantity(int quantity){
this.qunatity -= quantity;
}
}
staticフィールドを使用したテストケースは次のようになります。
import junit.framework.TestCase;
public class InventoryTest extends TestCase {
private static Inventory inventory = new Inventory(0);
/**
* 在庫数を増やすテスト
*/
public void testIncreaseQuantity(){
inventory.increaseQuantity(1000);
assertEquals(1000, inventory.getQuantity());
}
/**
* 在庫数を減らすテスト
*/
public void testDecreaseQuantity(){
inventory.decreaseQuantity(1000);
assertEquals(0, inventory.getQuantity());
}
}
このテストを実行します。
..
Time: 0
OK (2 tests)
成功しました。
しかし、このJUnitテストには問題があります。
例えば、在庫数を設定するsetterメソッドが追加されたとします。
単体テストも追加することになります。
import junit.framework.TestCase;
public class InventoryTest extends TestCase {
private static Inventory inventory = new Inventory(0);
public InventoryTest(String testMethodName){
super(testMethodName);
}
/**
* 在庫を設定するテスト
*/
public void testSetQuantity(){
inventory.setQuantity(1000);
assertEquals(1000, inventory.getQuantity());
}
/**
* 在庫数を増やすテスト
*/
public void testIncreaseQuantity(){
inventory.increaseQuantity(1000);
assertEquals(1000, inventory.getQuantity());
}
/**
* 在庫数を減らすテスト
*/
public void testDecreaseQuantity(){
inventory.decreaseQuantity(1000);
assertEquals(0, inventory.getQuantity());
}
}
このテストを実行すると、次のような結果になります。
(若干表示内容は省略しています。)
..F.F
Time: 0
There were 2 failures:
1) testIncreaseQuantity(InventoryTest): expected:<1000> but was:<2000>
2) testDecreaseQuantity(InventoryTest): expected:<0> but was:<1000>
FAILURES!!!
Tests run: 3, Failures: 2, Errors: 0
何も変更していないはずのテストが失敗しました。
これは、テストメソッド間に依存関係があったためです。
staticフィールドは、各テストメソッドを実行するためのJUnitテストクラスのインスタンス間で共有されます。
最初に紹介したJUnitテストが成功したのは、テストの実行順が偶然正しかったからです。EclipseのJUnitプラグインを使用している場合等では、JUnitテスト中のテストメソッド1つだけを実行することも気軽にできますが、testDecreaseQuantityだけを実行していればテストは失敗します。また、何かの理由でテストメソッドの実行順が変わればテストは失敗します。
この例では、staticフィールドをわかりやすい使い方をしているため、問題箇所の特定、修正は容易です。しかし、実際はもっと複雑で長いコードの中に紛れ込むことになります。そうすると対応するのに時間がかかってしまいます。
また、テストコードではなく、テスト対象のコードのほうにstaticフィールドが存在し、それを各テストメソッド実行前に初期化していなかったためにテストが失敗するような場合もあります。このような場合は、しっかりsetUpメソッドかtearDownメソッドでstaticフィールドの初期化を行うようにしなければなりません。この時に、コンストラクタで初期化をしないように注意してください。コンストラクタは、全てのテストメソッドが実行されるより前に全て実行されてしまうからです。
依存関係のない独立したJUnitテストは次のようになります。
import junit.framework.TestCase;
public class InventoryTest extends TestCase {
private Inventory inventory = new Inventory(0);
/**
* 在庫を設定するテスト
*/
public void testSetQuantity(){
inventory.setQuantity(1000);
assertEquals(1000, inventory.getQuantity());
}
/**
* 在庫数を増やすテスト
*/
public void testIncreaseQuantity(){
inventory.increaseQuantity(1000);
assertEquals(1000, inventory.getQuantity());
}
/**
* 在庫数を減らすテスト
*/
public void testDecreaseQuantity(){
inventory.decreaseQuantity(1000);
assertEquals(-1000, inventory.getQuantity());
}
}
inventoryをテストメソッド内にローカル変数として持たせると、よりテストメソッドが独立していることが明確にはなります。
私はコードの重複とのトレードオフを考えて、こういう場合はprivateフィールドとしています。
このぐらいのコードならどちらでも大差はありません。
ただ現実には、1つのテストクラス中に20、30という量のテストメソッドを書くことも珍しくありませんので、そういう場合には何度も使用するクラスはprivateフィールドにしておいて、必要な共通の初期化をsetUpメソッドで行うようにしたほうがいいと思います。
staticフィールドの使用は、finalを付けた定数や、初期化に時間がかかるもの(※例えばHibernateのような)に関してテストケース内で共有したい、といった場合のみに限定し、注意して取り扱うようにしましょう。
再現可能
変更されていないテストケースは、何度実行しても同じ結果にならなければなりません。
JUnitテストのメリットの一つは何度でも自動で回帰テストが行えることです。
しかし、JUnitテストの作り方によっては、ずっと成功していたはずのテストがある時から失敗するようになったり、酷い時はあるタイミングで実行した場合のみ失敗したりするようになります。
特に、次のようなテストには注意が必要です。
- データベースを使用しているコンポーネントのテスト
- Dateを扱うコンポーネントのテスト
例えば、「入庫する」というユースケースを例にして考えましょう。
次のコードは、入庫された数量分在庫を増やし、データベースを更新するクラスです。
package control;
import dao.InventoryDAO;
import entity.Inventory;
/**
* 在庫コントローラ
*/
public class InventoryControl {
/**
* 入庫する
* @param inventory
* @param quantity
*/
public void storeIn(Inventory inventory, int quantity){
inventory.increaseQuantity(quantity);
InventoryDAO dao = new InventoryDAO();
dao.update(inventory);
}
}
在庫コントローラクラスのテストを次のように行うとします。
package control;
import junit.framework.TestCase;
import transaction.TransactionManager;
import dao.InventoryDAO;
import entity.Inventory;
public class InventoryControlTest extends TestCase {
/**
* 入庫するテスト
*/
public void testStoreIn(){
//トランザクション開始
TransactionManager.beginTransaction();
//DBからID=1の在庫を取得
InventoryDAO dao = new InventoryDAO();
Inventory inventory = dao.find(1);
//結果の予想値
final int expectedQuantity = inventory.getQuantity() + 1000;
//1000個入庫
InventoryControl control = new InventoryControl();
control.storeIn(inventory, 1000);
//DBから在庫を再取得して、正しく更新されているかを確認
Inventory actualInventory = dao.find(1);
assertEquals(expectedQuantity, actualInventory.getQuantity());
//トランザクションコミット
TransactionManager.commit();
}
}
データベースより在庫を取得、コントローラーで1000個の在庫の入庫処理を行い、その結果正しく更新されているのかを検証しています。
このテストケースには、問題があります。
このテストケースを繰り返し実行し続けると、実行する毎にデータベース内の在庫数が増えていき、最後には桁あふれを起こします。そうすると、今まで成功していたはずのテストが失敗するようになります。
これは、特にデータベースを使用したアプリケーションのテストを行う場合には、よく起こる問題です。その他にも、ファイルを更新する場合など、処理の過程でデータが永続化されてしまう機能も同様です。
解決策は大きく分けて2つの方法があります。
- 永続化を行うクラスをMockオブジェクトで置き換える
- テスト開始時または終了後にテスト環境を初期化する
1の方法は、上記のコントロールクラスをデータベースから切り離します。下位のレイヤーのコンポーネントに依存している部分をMockに置き換えることで、純粋な単体テストとなります。コントロールクラスの単体テストという本来の目的から考えれば、この方法が有力です。ただし、切り離したデータベース部分、つまりDAOの単体テストも行う必要があります。DAOの単体テストに関しては、依然として同じ問題が解決されないまま残っています。この問題は、2の方法で解決します。
2の方法では、テスト終了後の環境とテスト開始前の環境が同じになるように事前処理、または事後処理を行います。上記の例ならば、最も簡単にテスト開始前の環境に戻す方法は、トランザクションのコミットを行っている部分を、ロールバックに変えてしまうことです。これでほぼ元の環境に戻すことができます。ただし、主キーをシーケンスで自動採番している場合は、シーケンスはロールバックされないため完全には元に戻せません。現実的には、シーケンスが毎回カウントアップされていっても問題はないことのほうが多いと思いますが、厳密に戻したいならば事後処理に追加する必要があります。
最終更新:2007年04月22日 21:46