LoginSignup
352
459

はじめに

この記事は レガシーコード改善ガイド: 保守開発のためのリファクタリング を参考に手を動かしてみて、ある程度自分の中で体系的にまとまった知識のアウトプットです。

この記事で扱う内容

この記事で扱うのは主にレガシーコードで単体テストを書く際のハードルになりがちな 依存関係の排除 に関する手法を紹介します。

この記事を読んだ後に、

  • 『この観点を持っておけば単体テストをスムーズに書いていけそう!』
  • 『今までモック使ってたけど意外とモック使わなくても書けるね!』

となったらいいな、と思います。

ちなみに、今まであんまりテスト書いたことないよーて人は以下の記事など参考にして一度やってみてください。

前提の話:
この記事の本旨は「テスト書きにくいプロダクトコードも依存関係を排除すれば楽にテスト書けるよ」なので、それ設計的にアウトでは?リファクタリング耐性低くない?みたいな話は度外視してます。

もくじ

テスト駆動不具合修正 or リファクタリング手順

テスト駆動開発者は既存コードの不具合に対しても「不具合の修正時には必ず先に不具合を再現する自動テストを書いてから修正する」という鉄の掟を遵守します。

不具合修正時のテストは、次のような手順で行います。

  1. 手元で不具合を再現させる。
  2. コードを注意深く調べ、不具合を発生させている最小の部分を絞り込む。
  3. 最小レベルで不具合を再現させ、不具合が修正されたら通るような自動テストコードを書く。
  4. 3で書いたテストコードを実行し、落ちることを確認する。
  5. 不具合を修正する。
  6. 3で書いたテストコードが通ることを確認する。
  7. 既存のすべてのテストを実行し、不具合修正が他の部分を壊していないことを確認する。

リファクタリングの際も同様に、まずはテストを整備します。

上記はテスト駆動開発の思想を取り入れたテスト駆動不具合修正の手順ですが、レガシーコードを扱う場合には手順が増えることが多いです。

  1. 手元で不具合を再現させる
  2. コードを注意深く調べ、不具合を発生させている最小の部分を絞り込む
  3. ★ 単体テストのテスト対象コードを絞る
  4. 最小レベルで不具合を再現させ、不具合が修正されたら通るような自動テストコードを書く
  5. ★ テストが書けない場合に依存関係を排除する
  6. 4で書いたテストコードを実行し、落ちることを確認する
  7. 不具合を修正する
  8. 4で書いたテストコードが通ることを確認する
  9. 既存のすべてのテストを実行し、不具合修正が他の部分を壊していないことを確認する

上記を見てみると、

  • 単体テストのテスト対象コードを絞る
  • テストが書けない場合に依存関係を排除する

の2つが増えていますよね、これ、かなり厄介なんです。なぜなら、こいつらのせいで単体テストを書く気力がゴッソリ持っていかれるからです。これに対抗するには闇のコードに対する防衛術を身に着ける必要があります。

───怖いか?ポッター。

なぜテストが書けないのか

テストが書けないのは、あるいは書けても自信が持てないのは詰みポイントの対処法を知らないから、それだけです。

  • 単体テストのテスト対象コードを絞る
  • テストが書けない場合に依存関係を排除する

まず、これら2つの手順がなぜ発生するのか、その原因を説明します。それはだいたい以下のようなコードに起因します。

  • テスト対象を絞らなきゃいけない原因
    • そもそもテスト対象メソッドが大きすぎて分岐網羅できない
      • 1メソッドで2000行とかある、まさにモンスター
    • メソッド内の複雑な条件分岐・ループ構造のせいで振る舞いが把握できない
      • 1つのif文の中に200行の処理みたいな錯乱メソッド
  • 依存関係を排除しなきゃいけない原因
    • そもそもテストクラスでテスト対象クラスのインスタンス化ができない
      • コンストラクタでDBアクセスのメソッド呼び出しがある
      • コンストラクタでメソッド呼び出し、そこからさらに別メソッド呼び出しという不穏な流れ
      • インスタンス化のパラメータ(コンストラクタ引数)の生成が難しい
    • テストメソッド内の依存関係を解決できない
      • staticメソッドの利用
      • DBアクセスのメソッド呼び出し
      • テスト対象メソッドがいろんなオブジェクトに依存している

などなど、分かる人には分かると思いますが他にもいろいろあります。めまいがします。

こういった理由で、『どう書こう、こうしたらいける?いや、こうか...?』と言っている間に業務終了のお時間ですなんてこともあるかもしれません。頑張ってもテストクラスのsetUp()メソッドの中身が20~30行とかになって、『いやーーーこれはちょっと...』となるかもしれません。私はそうなってました。

ですがこれらはある程度対処法が存在しているので、分かってしまえばなんてことありません。

依存関係を排除できればテストは書ける

テストが書けない原因で2つ挙げましたが、冒頭で記載しているように本記事は依存関係排除の手法に焦点をあてています。

というのも、「モンスターメソッドの中で単体テスト対象を絞る」についてはIDEの機能による機械的なメソッド抽出やクラス抽出によって簡単にテスト対象を狭めることができるからです。(メソッド抽出だけしてしまえば基本的に解決できる)1機械的なリファクタリングで、テストで保護する対象を安全に抽出してあげましょう。

一方で、依存関係の排除についてはJavaであれば MockitomockStatic() の記事が大量に出てくることからわかるように苦しんでいる人が多い印象です。(なので記事を書いて知識のおすそ分けをしようと思いました)

そんな人こそ、「自分は今、依存関係の解決ができなくてハマっているんだな」という気付きを得て、依存関係を適切に排除できる手法を知る必要があります。この点に気が付くことができれば、場当たり的な対応ではなく適切な方法を試せると思うのです。知っていれば、ちゃんとググることができるので、書けないテストがなくなっていくわけです。

余談ですが、最近の若手は強力なモックライブラリとともに育ったモックネイティブな世代なので、単体テストを書くためにモックオブジェクトを使ってプロダクトコードに穴をあけがちな気がしています(知らんけど)

モックオブジェクトを使えば、自由にオブジェクトの偽装やメソッドの振る舞いを定義できますが、使い過ぎるとテストコードのリファクタリング耐性や可読性は下がります。また、テスト駆動開発界隈の2大派閥(モックオブジェクトを積極的に使うロンドン学派 vs あまり使わない古典派)の抗争もあるので、「モックオブジェクトの使用は最低限にしたほうがいい」というのが私のスタンスであります。

結局場合分けでしょという話にはなるのですが、モックを使わずに単体テストを書くハードルを下げる手法をいくつか紹介するので、もしそっちの方が楽に書けるかも、なんてことがあればぜひ使ってみてください。

※ そもそも「モックを使わなきゃ依存関係を排除できない実装」がよろしくない場合は多いのですが、レガシーコードが前提なので目をつむってください。

依存関係を排除するためのカギになる考え方

依存関係を断ち切るためのポイント、それが 接合部 という概念です。

レガシーコード改善ガイドでは以下のように説明されています。

接合部とは、その場所を直接編集しなくても、プログラムの振る舞いを変えることができる場所である。

初見だと理解しにくいかもしれませんが、つまりは接合部を見つけることで(あるいはコードに手を加えて作成することで)、他のオブジェクトや振る舞いに依存している部分を置き換えることができるようになるので、依存関係を排除することができるようになる という話です。

例えば、テスト対象メソッドが他クラスのDBアクセスメソッドに依存している場合を考えてみてください。テストを実行する際にはテスト用DBとの接続が必要になるし、データのセットアップが必要だし、単体テストのパフォーマンス(速度)は落ちるでしょう。そもそも、DBアクセスして結果を取得するメソッドの振る舞いについては、テスト対象メソッドから呼ばれるメソッドなので、この部分はテストで保護する対象外です。

このような場合に、DBアクセスの依存関係を排除するための接合部を作って偽装オブジェクトを渡せるようにします。こうやって接合部(任意の振る舞いを注入できる部分)を作ることで、他のクラスや外部システムの影響を受けずに、テスト対象クラスの振る舞いを確認することができるようになる のです。(モックはこれがちょー簡単にできちゃう)

以下では、接合部をつくり偽装オブジェクトを注入することで、単体テストを書くための手法をサンプルコードを用いて紹介します。

書けない単体テストがなくなる2ステップ

接合部をうまく使うことで、単体テスト実行を困難にする依存関係を排除することができ、たいていのクラスやメソッドに単体テストをあてることができます。

書けない単体テストがなくなる(依存関係を排除するための)2ステップは以下のようになります。

Step1.依存関係を排除するための接合部を作る

  • どこが接合部になるのかに気がつく
  • あるいは、どこを接合部にしたらいいかを特定し、作る

Step2.接合部に偽装オブジェクトを注入する

  • 接合部に注入する偽装オブジェクトを作成する
  • 偽装オブジェクトを接合部に注入する

注入する偽装オブジェクト(テストダブル)の例:

  • インターフェースを実装した偽装オブジェクト
  • テストクラスで生成した偽装オブジェクト
  • モックライブラリ(JavaはMockito)を活用した偽装オブジェクト

慣れてくると手癖でできるようになるので、知らなかった!という方は以下を参考にやってみてほしいです。

実際にちょっとやってみよう

というわけで、具体的にどうすればいいかというサンプルを簡単に示そうと思います。

  • サンプル1:機械的なメソッド抽出とテストクラスでのオーバーライド
  • サンプル2:インターフェースの抽出とテストクラスで偽装オブジェクトの注入

汎用的で簡単、安全に接合部を作ることができるのはこれら2つだと思っているので今回はこれらを紹介します。(※ 他にもtipsはあるけど、基本は上記の2つができたらほとんど対応できる認識)

サンプル1、サンプル2ともに以下の2ステップで解説します。

  • Step1.依存関係を排除するための接合部を作る
  • Step2.接合部に偽装オブジェクトを注入する

大前提:
基本的にテストがない状態で既存コードに変更を加えること(振る舞いを変えるリスクを生む変更)は避けるべきです。
テストがない状態でプロダクトコードをいじる場合は、IDEの機能だけを使うことを強くオススメします。(ヒューマンエラー怖い)
とは言え明らかに振る舞い変えてない場合はOKでしょ!というケースもあると思うので、基準はメンバーや上司と相談ですね!

サンプル1:機械的なメソッド抽出とテストクラスでのオーバーライド

手順は以下です。

  1. 接合部として抽出したいメソッドを特定する
  2. 対象メソッドをIDEのリファクタリング機能で機械的にメソッド抽出する
  3. アクセス修飾子をテストクラスで使えるように変更する(可視性を上げる)
  4. 抽出したメソッドには @VisibleForTesting アノテーションをつける
  5. テストクラスでテスト対象クラスをインスタンス化する
  6. その際に抽出したメソッドをオーバーライドし、振る舞いを実装する

Step1.依存関係を排除するための接合部を作る

:sob: < テスト対象のMyClass #getHeader()HogeUtils #checkUseHoge() に依存しており、テスト実行ができない。

BEFORE
// テスト対象のクラス
public class MyClass {

    public String getHeader(int value) {

        // HogeUtils #checkUseHoge()を呼び出した先でゴチャゴチャやっているので
        // この依存関係を排除しないとテストが実行できないと仮定
        if (HogeUtils.checkUseHoge(value)) {
            return "hoge";
        }
        return "fuga"
    }
    
    // その他処理
}

:triumph: < IDEの機能で、インスタンスメソッドとして機械的にメソッド抽出する。

AFTER
// テスト対象クラス
public class MyClass {

    public String getHeader(int value) {
    
        // staticメソッドはMyClassのインスタンスメソッドとして抽出
        if (getHogeCheckResult(value)) {
            return "hoge";
        }
        return "fuga"
    }

    // メソッド抽出でここが接合部になる
    @VisibleForTesting
    boolean getHogeCheckResult(int value){
        return HogeUtils.checkUseHoge(value);
    }
    
    // その他処理
}

Step2.接合部に偽装オブジェクトを注入する

:relaxed: < テストクラスでメソッドをオーバーライドし、任意のオブジェクトを渡せるようにする。

MyClassTest.java
// テストクラス
public class MyClassTest {

    private MyClass it;

    @BeforeEach
    void setUp(){
        // テストクラスでメソッドをオーバーライド
        it = new MyClass(){
            @Override 
            boolean getHogeCheckResult(int value){
                return true; // 渡したい真偽値はテストクラスで自由にハンドリング
            }
        };
    }
    
    @Test
    @DisplayName("1を渡した場合にhogeが返る")
    public void getHeaderTest(){
        assertThat(it.getHeader(1), is("hoge"));
    }
}

こうすることで自信で定義した任意の偽装オブジェクト(今回であればboolean型のtrue)を返すことができます。モックライブラリの mockStatic() を使わなきゃいけない場面でも、サクッと振る舞いを定義できるし、めちゃくちゃ簡単。

Singletonインスタンスにアクセスしているメソッドや、テスト対象メソッドが依存するフィールド変数にDBアクセスで値をセットしている場合とか、わざわざモックを使わずテスト環境を整えられるので便利です。

上記のサンプルコードで示したもののほかにも、もちろん場合に応じて任意の偽装オブジェクトを渡すことができます。以下にちょっとだけ例を示しておきます。

// 例1:任意のリストを返したい場合
public class TargetClassTest1 {

    // テスト対象クラス
    private TargetClass it;

    @BeforeEach
    void setUp(){
        // テストクラスでメソッドをオーバーライド
        it = new TargetClass(){
            @Override 
            public List<String> getList(){
                // 偽装オブジェクトを生成して返すようにハンドリング
                // オブジェクトの生成を別のメソッドに抽出してもいい
                List<String> fakeList = new ArrayList<>();
                fakeList.add("hoge");
                fakeList.add("fuga");
                return fakeList;
            }
        };
    }

    @Test
    void hogeTest(){
        // テスト対象メソッドでgetList()が使用されたときにfakeListを渡せる
    }
}

// 例2:振る舞いに影響しないvoidメソッドの場合
public class TargetClassTest2 {

    // テスト対象クラス
    private TargetClass it;

    @BeforeEach
    void setUp(){
        // テストクラスでメソッドをオーバーライド
        it = new TargetClass(){
            @Override 
            public void updateSomething(){
                return; // なにもしないようハンドリング
            }
        };
    }

    @Test
    void hogeTest(){
        // テスト対象メソッドでupdateSomething()が使用されたときに
        // 何もしないように振る舞いを定義できる
    }
}

こんな感じでメソッド抽出するだけで、テストクラス内で定義した任意の偽装オブジェクトをいろいろと注入できます。また、『テスト対象クラスをインスタンス化したいが依存関係がつらくてインスタンス化できない...』『テスト対象クラス以外のオブジェクトのハンドリングもしたい...』という場合もメソッド抽出 → テストクラスでnewするときにオーバーライド の流れで対応できることが多いので使ってみてください。

※ もちろんこれでは対応できないこともある

実はクラスの内部実装に依存している:
依存関係の排除はできるものの、テストクラスでテスト対象クラスのメソッドをオーバーライドする場合、テストクラスはテスト対象クラスの内部実装に依存します。つまり中身を知りすぎている状態なのです。
テストが「外側から見たメソッドの振る舞いの検証」になりきれてないところに注意が必要です。

サンプル2:インターフェースの抽出とテストクラスで偽装オブジェクトの注入

手順は以下です。

  • 対象メソッド内で接合部として機能する部分を特定する
  • テスト対象クラスが依存しているクラス(オブジェクト)を特定する
  • テスト対象クラスが依存しているクラスに対応するインターフェースを定義する
  • テスト対象クラス内の依存関係をインターフェースに依存させるように修正する
  • 修正した部分をメソッド抽出する or コンストラクタとして定義し、接合部を作る
  • テストコードで、依存クラスの偽装オブジェクトを作成し、依存関係を注入する

※ 今回みたいなインターフェースの抽出については以下の記事が手順の参考としておすすめです。(私はこの記事見ながら最初やってみて感動した)

Step1.依存関係を排除するための接合部を作る

:sob: < テスト対象のUser #getUseInfo()UserDao #getUserInfoFromDB() に依存しており、DBアクセスしたくない。

BEFORE
// テスト対象クラス
public class User {

    public List<String> getUseInfo(String userId){
        UserDao dao = new UserDao();
        // DBアクセスを伴う
        List<String> userInfo = dao.getUserInfoFromDB(userId);

        // 何かしらの処理
        doSomething(userInfo);
        
        return userInfo;
    }

}

// テスト対象が依存関係を排除したいクラス
public class UserDao {
    
    // DBアクセスするメソッド
    public List<String> getUserInfoFromDB(String userId) throws SQLException {

        List<String> userInfo = new ArrayList<>();
        // 指定されたユーザーIDの情報を取得する処理があるとする
        return userInfo;
    }
}

:triumph: < UserDao の部分を外側から渡せるように修正する。

AFTER
// 接合部となるインターフェース
public interface IUserDao {
    public List<String> getUserInfoFromDB(String userId) throws SQLException;
}

// テスト対象クラス
public class User {

    private IUserDao dao;

    // 既存動作担保のため引数なしコンストラクタ
    public User() {
        this(new UserDao());
    }

    // テスト用の引数ありコンストラクタ
    public User(IUserDao dao) {
        this.dao = dao;
    }

    public List<String> getUseInfo(String userId){

        // DBアクセスを伴う処理
        List<String> userInfo = dao.getUserInfoFromDB(userId);

        // 何かしらの処理
        doSomething(userInfo);
        
        return userInfo;
    }

}

// テスト対象が依存関係を排除したいクラス
public class UserDao implements IUserDao {
    
    // DBアクセスするメソッド
    @Override
    public List<String> getUserInfoFromDB(String userId) throws SQLException {

        List<String> userInfo = new ArrayList<>();
        // 指定されたユーザーIDの情報を取得する処理
        return userInfo;
    }
}

Step2.接合部に偽装オブジェクトを注入する

:relaxed: 依存先をインターフェースにすることで、DBアクセスした結果の偽装オブジェクトをテストクラスで定義して渡すことができる。

UserTest.java
// テストクラス
public class UserTest {

    // テスト対象クラス
    private User it;

    // 偽装オブジェクトを作成するためのダミーのUserDaoクラス
    private class FakeUserDao implements IUserDao {

        @Override
        public List<String> getUserInfoFromDB(String userId) {
            return Arrays.asList("_mi", "_mi@example.com");
        }
    }

    @BeforeEach
    void setUp(){
        // テスト用の偽装オブジェクトを作成してUserクラスに注入
        it = new User(new FakeUserDao());
    }

    @Test
    public void getUserInfoTest() {

        List<String> expected = // User #doSomething()後の期待結果

        // テスト対象のメソッドを実行
        List<String> actual = it.getUseInfo("12345");

        // 結果を検証
        assertThat(actual, is(expected));
    }
}

これは一般的には Dependency Injection(DI)と言われる、オブジェクト間の依存関係を外部から注入する設計パターンです。

DIにも以下の種類があるようです。

  • コンストラクタインジェクション
  • セッターインジェクション
  • フィールドインジェクション

依存性注入という意味合いでDIという言葉を使いましたが、これはSOLID原則の D であるDependency Inversion Principle(依存性逆転の原則)を遵守した設計にもなっています。

今回の例で言えば、高レベルのモジュール(メソッドを利用する User クラス)は低レベルのモジュール(メソッドを利用される UserDao クラス)に依存の矢印が伸びていました。

これらの矢印をインターフェースに向ける(UserIUserDaoUserDaoIUserDao)ことで、高レベルのモジュールと低レベルのモジュールの間の結合度が低くなり、変更容易性が向上します。さらにDI(Dependency Injection)しやすくなります。つまり、テスト容易性も向上します。

もはや、「依存関係を排除するためのカギになる考え方」は、「依存性逆転の原則を適用すること」とも言い表すことができると言っても過言ではありませんね。

// 2024/03/19 追記

偽装オブジェクトについて

モックオブジェクトをあまり使わず、本物に近そうな偽装オブジェクトを書く方法を一部紹介しましたが、私もちょこちょことモックオブジェクト使ってます。

気を付けているのはリファクタリング耐性がないような実装をできるだけ書かないことくらいです。例えば、 mockStatic() を使ったテストコードなどは実装をゴリゴリにテストクラスに書いちゃっているので、けっこうもろいテスト(Fragile Test)になってしまっていると思います。

なのですが、mockStatic() を使わないとどうしようもないみたいケースにこの前遭遇しまして、

  • 呼び出し元までテストかぶせる
  • mockStatic() でその場をしのぐ

の2択で mockStatic() 選びました。

この辺の選択の功罪やいい単体テストとは?についてまだまだ体系的にまとまっていないので、とりあえず 単体テストの考え方/使い方 でも読んでまとめてみたいと思いました。(この本高いのよね......)

隣の新規開発の芝は、実はそれほど青くない

一応この記事はレガシーコード改善ガイドの読書感想文なので感想を書くのですが、これでした。

おわりに

依存関係を排除する手法を身に着けてから書けない単体テストがほぼなくなりました。難しいやつもこれよくないなーと思いながらもきっと書ききれます(ほんとか?)

いろいろ書けるようになって分かったのは、単体テストってめちゃくちゃ奥が深いんだよねーてことです。単体テストは書けるものの、単体テストの「あるべき」を問われると答えられません。でも具体的に困った時点で勉強するのが一番効率良いのでこれはこれでアリですね。

ゆーてますけども、まずはテストを書こうとしてみること。『逃げたら1つ、進めば2つ』とスレッタも言うてます。手を動かそう!

おわり

  1. レガシーコード改善ガイドの中では スプラウトメソッドスプラウトクラス という名前で表現されている。ググるなら スプラウトメソッド とか メソッド抽出 でいい感じの記事が出てくるはず。

352
459
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
352
459