概要
レガシーコードでは、単体テストを書こうとした際に、しばしば既存のstaticメソッドが妨げになることがある。
この記事では、「新たにそのようなコードを増やさないようにするための原則(ルール)」と「既存のstaticメソッドが妨げになっているケースの解決方法」を示す。
レガシーコードに対して、はじめて単体テストを書くとき向けの記事。
問題
下記のLegacyService#execute()
のようなメソッドに対して、うまく単体テストを書くことができない。
テスト対象メソッドから呼び出されているloadFromDatabaseメソッドがデータベースへのアクセスを必要とするため、アクセスが不可能な環境から実行した際には、例外が発生してしまう。
仮にデータベースへのアクセスが可能だったとしても、そのデータベースから安定して同じデータを取得することは難しく、また実行時間がかかってしまう。
public class LegacyService {
public void execute() {
// ここにテスト対象のコードが入る
// 問題のコード
Object data = loadFromDatabase();
// ここにテスト対象のコードが入る
}
// クラスの外からも呼ばれているようなstaticメソッド
public static Object loadFromDatabase() {
// データベースにアクセスするような処理
}
}
データベースにアクセスする処理をMockで差し替えればよいのだが、loadFromDatabaseメソッドはstaticメソッドとして宣言されているため、それが困難である。1
また、loadFromDatabaseメソッドをインスタンスメソッドに変更すれば良いようにも思えるのだが、すでに他のクラス・メソッドからも参照されている場合、単純に変更することができない。
問題を解決するための原則(ルール)
staticメソッドに下記のような条件を満たす処理を書いてはいけない。
1. データベースとやり取りする
2. ネットワークを介した通信をする
3. ファイルシステムにアクセスする
4. 実行するために特別な環境設定を必要とする(環境設定ファイルの編集など)
(引用: レガシーコード改善ガイド / 2.1 単体テストとは)
staticメソッドは、円周率や平方根を求める関数などの「(少なくとも、そのシステムのそのメジャーバージョンでは)どのような実行環境においても、引数に対する結果が変わることのない定数・関数」のみに利用すべきである。
内部ロジックで現在時刻を利用するなど、結果が再現可能ではない関数は、staticメソッドにすべきではない。
既存コードの問題をどのように解決するか
しかし、上記の原則を破っている既存コードに対しては、どうすればよいのか。
このような場合、基本的にはテスト対象コードのリファクタリングをおこなう必要がある。commitを分けて、ステップを追って修正していこう。
ステップ0: 失敗する単体テストを用意する
public class LegacyServiceTest {
@Test
public void test() {
execute();
}
}
ステップ1: ラップするインスタンスメソッドを作る
さて、ここからが本番。まず、問題となっているloadFromDatabaseメソッドをラップするloadDataメソッドを作成する。
このとき、そのメソッドをprivateで宣言してはいけない。また、@VisibleForTestingアノテーションをつけている。これらの理由はステップ2で説明する。
import com.google.common.annotations.VisibleForTesting;
public class LegacyService {
public void execute() {
// ここにテスト対象のコードが入る
Object data = loadFromDatabase();
// ここにテスト対象のコードが入る
}
// 新しく追加したインスタンスメソッド
@VisibleForTesting
Object loadData() {
// もともと存在していた、問題のstaticメソッドを呼び出す
return loadFromDatabase();
}
// もともと存在していたstaticメソッド
public static Object loadFromDatabase() {
// データベースにアクセスするような処理
}
}
ステップ2: テストコードでステップ1の実装をMockする
テストコードで、ステップ1にて作成したloadDataメソッドの実装をMockで上書きする。
public class LegacyServiceTest extends LegacyService {
@Test
public void test() {
execute();
}
@Override
Object loadData() {
// ここで実装をMockする
}
}
この上書きを可能にするため、ステップ1ではloadDataメソッドの可視性をパッケージプライベートにしていた(protectedでも可)。
テストクラスのために可視性を上げており、その意図をわかりやすくするために@VisibleForTestingアノテーションをつけている。
また、上記ではテストクラス自身をテスト対象クラスのサブクラスにするSelf Shuntアプローチを使っている。もちろん、他の書き方でも問題ない。
ステップ3: テスト対象メソッドの実装を変える
テスト対象メソッドであるexecuteメソッドについて、元々のloadFromDatabaseメソッドの呼び出し部分を、ラップしたloadDataの呼び出しに置き換える。
public class LegacyService {
public void execute() {
// ここにテスト対象のコードが入る
// 新しく追加したインスタンスメソッドに呼び出し先を変える
Object data = loadData();
// ここにテスト対象のコードが入る
}
// 省略
}
このステップまでは、むやみにテスト対象メソッドを書き換えていない。修正範囲が限られるため、安全性の高いリファクタリングができる。
import com.google.common.annotations.VisibleForTesting;
public class LegacyService {
public void execute() {
// ここにテスト対象のコードが入る
Object data = loadData();
// ここにテスト対象のコードが入る
}
// 新しく追加したインスタンスメソッド
@VisibleForTesting
Object loadData() {
return loadFromDatabase();
}
// もともと存在していたstaticメソッド
public static Object loadFromDatabase() {
// ここでDBアクセスするような処理
}
}
まとめ
単体テストを書くためには、むやみにメソッドをstaticで宣言してはいけない。
しかし、既存のコードに問題がある場合でも、ステップを分けて修正することで、リファクタリングによる影響範囲を限定しつつ単体テストを書くことができる。
なお、既存のコードに問題がある場合の解決方法は、これ以外にも複数の手法が存在する。それらについては次の記事で。
次回:単体テストを書くためにstaticメソッドから脱却しよう #2 DI(Dependency Injection)
-
PowerMockなどのライブラリを使えば可能だが、実行が遅くなるなどの欠点があるため、今回は対象外とする ↩