概要
前回の単体テストを書くためにstaticメソッドから脱却しよう #1 原則・ラップメソッドでは、下記の2点を説明した。
- 実行環境により結果が変わるメソッドをstaticにしてはいけない
- 原則を破っている既存コードも、ステップを分けて影響範囲を小さくしながら修正できる
- たとえば、メソッドをラップすることで解決できる
今回の記事では、既存コードを修正するための他の方法として、他のクラスのstaticメソッドを呼び出している場合を例に、DI(Dependency Injection - 依存性の注入)という手法を紹介する。
なお、この記事では、Spring FrameworkなどのDIコンテナのフレームワークには言及しない。
問題
下記のLegacyClient#execute()
のようなメソッドに対して、うまく単体テストを書くことができない。
メソッド内の変数のhost
やport
の値を代入する処理では、GlobalControl
クラスのインスタンスメソッドにアクセスするのだが、データベースへのアクセスを必要としている。このため、アクセスが不可能な環境から実行した際には、例外が発生してしまう。
public class LegacyClient {
public void execute() {
// ここにテスト対象のコードが入る
// 問題のコード
String host = GlobalControl.getInstance().get("host", "localhost");
int port = GlobalControl.getInstance().getInt("port", 8080);
// ここにテスト対象のコードが入る
}
}
public final class GlobalControl {
private static final GlobalControl instance = new GlobalControl();
public static GlobalControl getInstance() {
return instance;
}
public String get(String key, String defaultValue) {
// データベースにアクセスするような処理
}
public int getInt(String key, int defaultValue) {
// データベースにアクセスするような処理
}
}
ラップメソッドには欠点がある
前回の記事では、メソッドをラップして解決する方法を紹介した。今回のケースも、その方法を適用することはできる。
しかし、この方法だと、複数のラップメソッドを作る必要があり、コードが冗長になる。
import com.google.common.annotations.VisibleForTesting;
public class LegacyClient {
public void execute() {
// ここにテスト対象のコードが入る
// 問題のメソッドをラップした
String host = get("host", "localhost");
int port = getInt("port", 8080);
// ここにテスト対象のコードが入る
}
// テストクラスで上書きするラップメソッド1
@VisibleForTesting
String get(String key, String defaultValue) {
return GlobalControl.getInstance().get(key, defaultValue);
}
// テストクラスで上書きするラップメソッド2
@VisibleForTesting
int getInt(String key, int defaultValue) {
return GlobalControl.getInstance().getInt(key, defaultValue);
}
}
DIによる解決方法
このため、今回はDIによる解決を試みる。
前回と同じように、commitを分けて、ステップを追って修正していこう。
ステップ0: 失敗する単体テストを用意する
public class LegacyClientTest {
@Test
public void test() {
new LegacyClient().execute();
}
}
ステップ1: 問題のクラスのインタフェースを抽出する
単体テストが用意できたところで、実際の修正に入る。
(IDEのリファクタリング機能などを利用して、)問題のGlobalControl
クラスで宣言されているインスタンスメソッドをインタフェースに抽出する。
public interface GlobalControlHolder {
public String get(String key, String defaultValue);
public int getInt(String key, int defaultValue);
}
public final class GlobalControl implements GlobalControlHolder {
private static final GlobalControl instance = new GlobalControl();
public static GlobalControl getInstance() {
return instance;
}
@Override
public String get(String key, String defaultValue) {
// データベースにアクセスするような処理
}
@Override
public int getInt(String key, int defaultValue) {
// データベースにアクセスするような処理
}
}
ステップ2: staticメソッドの呼び出しをインスタンス作成時に変更する
GlobalControl
インスタンスをメソッド内で取得するのではなく、インスタンス作成時(コンストラクタ)に取得するようにする。また、このときのメンバ変数は先ほど抽出したインタフェースGlobalControlHolder
で宣言するようにする。
そして、executeメソッドでは、メンバ変数globalControl
を参照するように書き換える。
public class LegacyClient {
// インスタンスをローカルからメンバ変数に移し、保持する
private final GlobalControlHolder globalControl;
public LegacyClient() {
this.globalControl = GlobalControl.getInstance();
}
public void execute() {
// ここにテスト対象のコードが入る
// メンバ変数 "globalControl" を参照するように書き換え
String host = globalControl.get("host", "localhost");
int port = globalControl.getInt("port", 8080);
// ここにテスト対象のコードが入る
}
}
ステップ3: DI用のコンストラクタを追加する
ステップ2で作成したコンストラクタは、コンストラクタ内でGlobalControlHolder
インスタンスを取得していた。それに加えて、GlobalControlHolder
を引数に取るような、別のコンストラクタを追加する。
public class LegacyClient {
private final GlobalControlHolder globalControl;
public LegacyClient() {
this.globalControl = GlobalControl.getInstance();
}
// DI用のコンストラクタ
public LegacyClient(GlobalControlHolder globalControl) {
this.globalControl = globalControl;
}
public void execute() {
// ここにテスト対象のコードが入る
String host = globalControl.get("host", "localhost");
int port = globalControl.getInt("port", 8080);
// ここにテスト対象のコードが入る
}
}
これにより、呼び出し元からGlobalControl
インスタンスを渡せるようになった。
コンストラクタを利用して、呼び出し元から依存モジュール(今回はGlobalControl
があたる)を注入することをコンストラクタインジェクションと呼ぶ。
ステップ4: テストコードで依存性を注入する
テストコードでは、GlobalControlHolder
インタフェースを実装したMockクラスを作成し、それをnewしてLegacyClient
のコンストラクタに渡すようにする。
public class MockGlobalControl implements GlobalControlHolder {
@Override
public String get(String key, String defaultValue) {
// デフォルト値を返す
return defaultValue;
}
@Override
public int getInt(String key, int defaultValue) {
// デフォルト値を返す
return defaultValue;
}
}
public class LegacyClientTest {
@Test
public void test() {
// テストコード用のMockクラスを注入する
new LegacyClient(new MockGlobalControl()).execute();
}
}
これにより、単体テストでは、データベースにアクセスしない実装で依存モジュールを差し替えることができる。
まとめ
別クラスのstaticメソッドを呼び出している場合の解決は、DI(Dependency Injection - 依存性の注入)が有効である。
また、前回同様、ステップを分けて修正することで、リファクタリングによる影響範囲を限定しつつ単体テストを書くことができる。