LoginSignup
7
3

More than 3 years have passed since last update.

Java: 単体テストを書くためにstaticメソッドから脱却しよう #2 DI(Dependency Injection)

Last updated at Posted at 2020-05-19

概要

前回の単体テストを書くためにstaticメソッドから脱却しよう #1 原則・ラップメソッドでは、下記の2点を説明した。

  1. 実行環境により結果が変わるメソッドをstaticにしてはいけない
  2. 原則を破っている既存コードも、ステップを分けて影響範囲を小さくしながら修正できる
    • たとえば、メソッドをラップすることで解決できる

今回の記事では、既存コードを修正するための他の方法として、他のクラスのstaticメソッドを呼び出している場合を例に、DI(Dependency Injection - 依存性の注入)という手法を紹介する。

なお、この記事では、Spring FrameworkなどのDIコンテナのフレームワークには言及しない。

問題

下記のLegacyClient#execute()のようなメソッドに対して、うまく単体テストを書くことができない。

メソッド内の変数のhostportの値を代入する処理では、GlobalControl クラスのインスタンスメソッドにアクセスするのだが、データベースへのアクセスを必要としている。このため、アクセスが不可能な環境から実行した際には、例外が発生してしまう。

LegacyClient.java
public class LegacyClient {
    public void execute() {
        // ここにテスト対象のコードが入る

        // 問題のコード
        String host = GlobalControl.getInstance().get("host", "localhost");
        int port = GlobalControl.getInstance().getInt("port", 8080);

        // ここにテスト対象のコードが入る
    }
}
GlobalControl.java
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クラスで宣言されているインスタンスメソッドをインタフェースに抽出する。

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を参照するように書き換える。

LegacyClient.java
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を引数に取るような、別のコンストラクタを追加する。

LegacyClient.java
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のコンストラクタに渡すようにする。

テストコード用のMockクラス
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 - 依存性の注入)が有効である。

また、前回同様、ステップを分けて修正することで、リファクタリングによる影響範囲を限定しつつ単体テストを書くことができる。

7
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3