TDDの考え方を活用してレガシーコードのリファクタリングに立ち向かう

はじめに

本記事はテスト駆動開発 Advent Calendar 2020 最終日の記事です。このアドベントカレンダーはスカスカなので、今からでもテスト駆動開発経験談などをエントリーしてもらえると嬉しいです!

目次

レガシーコードのリファクタリング

テスト駆動開発(以下、TDD)を学び始めた人が、現場でぶつかる壁の一つは「レガシーコード*1なのでTDDが使えない」だと思います。

「レガシーコードをリファクタリングしたいけど、リファクタリングするためのテストコードがない」「テストコードを書きたいけど、レガシーコードなのでテストコードが書きづらい」というジレンマに陥りがちです。

そこで本記事では、TDDの考え方を活用しつつ、まずは1つだけでもテストコードを注入することで、レガシーコードに立ち向かっていきます。*2

今回の題材

レガシーコードになりがちなコードの1つとして、現在時刻に依存しているなど、テストしづらい状況であるコードがあります。

例えば、以下のようなコード*3です。

//DeliveryDate.java
import java.time.LocalDate;
import java.time.Month;

public class DeliveryDate {
    public LocalDate getDeliveryDate(){
        LocalDate localDate = LocalDate.now();
        int day = localDate.getDayOfMonth();
        Month month = localDate.getMonth();
        int year = localDate.getYear();

        if(day >= 25){
            month.plus(1L);
        } else if (month.equals(Month.DECEMBER) && day >= 20) {
            month.plus(1L);
        }

        int lastDay;
        if(month.equals(Month.APRIL)) {
            lastDay = 30;
        } else if(month.equals(Month.JUNE)){
            lastDay = 30;
        } else if(month.equals(Month.SEPTEMBER)){
            lastDay = 30;
        } else if(month.equals(Month.NOVEMBER)){
            lastDay = 30;
        } else if(month.equals(Month.FEBRUARY)){
            if(year%4 == 0){
                lastDay = 29;
            } else {
                lastDay = 28;
            }
        } else {
            lastDay = 31;
        }
        return LocalDate.of(localDate.getYear(),
            localDate.getMonth(), lastDay);
    }
}

このコードは、配送日を指定するコードです。6行目で現在日時を挿入し、その日時の月末を配送日として設定します。しかし、現在日時が下旬や年末の場合は、配送日が次月に設定されます。

本章ではこのコードに対してリファクタリングを行っていきます。

最初のテストコード

さて、このようなコードに対して、どのようにテストケースを追加していけば良いでしょうか。

最初に作るべきテストコードでは「とりあえず動くテスト」を目指します。

今回の場合は以下のようなコードをとりあえず作ってみましょう。

//DeliveryDateTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class DeliveryDateTest {

    @Test
    void _配送日のテスト() {
        new DeliveryDate(); //DeliveryDateクラスを呼び出す
        assertEquals(1,1);
    }
} 

ただ、DeliveryDateクラスを呼び出しただけ*4です。

この状態でテスト実行をしてみましょう。もしもテスト実行してRedになった*5場合、以下の2つのどちらかが原因でしょう。

  • テストフレームワークの設定自体が間違っている
  • DeliveryDateクラスの呼び出しの際に必要な設定が足りない

このうち、他のクラスで同じテストフレームワークを用いて動いていた場合は、1つ目の原因の可能性は限りなく低いでしょう。つまり、今回のテストコードを実行することで、そもそも実装コードを実行できるのか確認することができます*6

テスト実行してGreenになった場合、次はメソッドを呼び出します。

//DeliveryDateTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class DeliveryDateTest {

    @Test
    void _配送日のテスト() {
        new DeliveryDate().getDeliveryDate(); //getDeliveryDateメソッドを実行する
        assertEquals(1,1);
    }
}

最初に書いたテストコードと同様、これもgetDeliveryDate()メソッドを実行できるのか確認することができます。

このようにテストコードを書くことで、実装コードを手軽に試すことができます。しかも一度書くと、自動で何度も実行することができます。

JaSST'18 Tokyo 招待講演*7 で、柴田芳樹さんは下記の発言をしていますが、今回の過程を写経すると実感ができると思います。

Unitテスト作成は自動でデバッグしている感覚

TDDは常に実装する感覚

仕様を理解してテストを作る

次は実装コードを見ながら期待値を当てはめていきます。

//DeliveryDateTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class DeliveryDateTest {

    @Test
    void _配送日のテスト() {
        LocalDate actualDate = new DeliveryDate().getDeliveryDate();
        assertEquals(LocalDate.of(2020,9,30),actualDate); //期待値を変更する
    }
}

今回の場合、getDeliveryDateメソッドを呼び出すと、今日の日付を元に月末の日付などが返ってきます。このファイルを作成した日付が2020年9月13日だったため、月末である2020年9月30日を期待値として設定しました。

別のテストケースを作る

続いて、実装コードを見て、別のテストケースを作ってみましょう。

//DeliveryDateTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class DeliveryDateTest {

    @Test
    void _小の月の月末になる場合のテスト() {
        LocalDate actualDate = new DeliveryDate().getDeliveryDate();
        assertEquals(LocalDate.of(2020,9,30),actualDate);
    }

    //大の月のテストケースを追加
    @Test
    void _大の月の月末になる場合のテスト() {
    LocalDate actualDate = new DeliveryDate().getDeliveryDate();
    assertEquals(LocalDate.of(2020,10,31),actualDate);
    }

}

小の月の月末のテストケースを元々作っていたので、大の月の月末の場合のテストケースも作成しようと考えました。

しかし、両方のテストケースにおけるDeliveryDateクラスの呼び出し及びgetDeliveryDateメソッドの呼び出しに違いがない(パラメータの設定などを行っていない)ため、これら2つのテストケースを実行しようとすると、必ずどちらかのテストがRedになります。

つまり、この方法だとうまくいかないことが分かります。このような場合、どうすれば良いのでしょうか。

依存関係を見つける

今回のテストがうまく行かない理由を考えてみましょう。

//DeliveryDate.java
import java.time.LocalDate;
import java.time.Month;

public class DeliveryDate {
    public LocalDate getDeliveryDate(){
        LocalDate localDate = LocalDate.now(); //現在の時刻を代入
        int day = localDate.getDayOfMonth();
        Month month = localDate.getMonth();
        int year = localDate.getYear();

        if(day >= 25){
            month.plus(1L);
        } else if (month.equals(Month.DECEMBER) && day >= 20) {
            month.plus(1L);
        }

        int lastDay;
        if(month.equals(Month.APRIL)) {
            lastDay = 30;
        } else if(month.equals(Month.JUNE)){
            lastDay = 30;
        } else if(month.equals(Month.SEPTEMBER)){
            lastDay = 30;
        } else if(month.equals(Month.NOVEMBER)){
            lastDay = 30;
        } else if(month.equals(Month.FEBRUARY)){
            if(year%4 == 0){
                lastDay = 29;
            } else {
                lastDay = 28;
            }
        } else {
            lastDay = 31;
        }
        return LocalDate.of(localDate.getYear(),
            localDate.getMonth(), lastDay);
    }
}

今回、テストケースがうまく行かない最大の理由は6行目です。 LocalDate.now()で現在の時刻を入れているため、テスト実行日時に依存してしまうのです。

この依存関係を削除する方法を考えましょう。

依存関係を削除する

日時に関する部分をメソッドとして切り出すことで依存関係を削除します。

実際にリファクタリングを行う手順を細かく区切って説明します。それぞれの手順ごとにテストは常に実行し続けた方が良いでしょう。

作業前の状態

作業前のテストコードの状態を改めて記載しておきます。

//DeliveryDateTest.java
import org.junit.jupiter.api.Test;

import java.time.LocalDate;

import static org.junit.jupiter.api.Assertions.*;

class DeliveryDateTest {

    @Test
    void _小の月の月末になる場合のテスト() {
        LocalDate actualDate = new DeliveryDate().getDeliveryDate();
        assertEquals(LocalDate.of(2020,9,30),actualDate);
    }

/* 大の月のテストケースはコメントアウトする
    @Test
    void _大の月の月末になる場合のテスト() {
        LocalDate actualDate = new DeliveryDate().getDeliveryDate();
        assertEquals(LocalDate.of(2020,10,31),actualDate);
    }
*/
}

なお、テストコードは「_大の月の月末になる場合のテスト」のテスト結果がRedになるため、今回はコメントアウトした状態で作業を始めます。

現在作成済みのテストケースが今回の狙いである依存関係部分の実装ロジックを通っているのか確認する

現在作成済みのテストケースを使って、カバレッジを計測します。実行すると、下記画像のようになります。

f:id:nihonbuson:20201210102003p:plain
カバレッジ計測結果

上記画像のうち、赤囲み部分(今回の狙いの部分である6行目)に注目してください。

行番号(6)の右隣にある色を確認します。ここが緑色だと既存のテストケースが実装ロジックを通っていることを示しています。もしも、ここが赤色だとテストケースが実装ロジックを通っていないことを示しているので、赤色の結果のまま、その行のリファクタリングをするのは止めましょう。

テスト実行に影響があるロジックを切り出す

先ほどのカバレッジ計測の結果、6行目はテストしている部分であることが分かったので、LocalDate localDate = LocalDate.now();の部分をメソッド化します。

//DeliveryDate.java
import java.time.LocalDate;
import java.time.Month;

public class DeliveryDate {
    public LocalDate getDeliveryDate(){
        LocalDate localDate = getNow(); //メソッド化
        int day = localDate.getDayOfMonth();
        Month month = localDate.getMonth();
        int year = localDate.getYear();

        if(day >= 25){
            month.plus(1L);
        } else if (month.equals(Month.DECEMBER) && day >= 20) {
            month.plus(1L);
        }

        int lastDay;
        if(month.equals(Month.APRIL)) {
            lastDay = 30;
        } else if(month.equals(Month.JUNE)){
            lastDay = 30;
        } else if(month.equals(Month.SEPTEMBER)){
            lastDay = 30;
        } else if(month.equals(Month.NOVEMBER)){
            lastDay = 30;
        } else if(month.equals(Month.FEBRUARY)){
            if(year%4 == 0){
                lastDay = 29;
            } else {
                lastDay = 28;
            }
        } else {
            lastDay = 31;
        }
        return LocalDate.of(localDate.getYear(),
            localDate.getMonth(), lastDay);
    }

    //メソッドの作成
    private LocalDate getNow() {
        return LocalDate.now();
    }
}

この時点でテスト実行して、テストがGreenを保っていることを確認します*8

テスト実行に影響があるメソッドにアクセスできる範囲を広げる

テストクラスからもアクセスできるように、privateからprotectedに変更します。

//DeliveryDate.java内のgetNowメソッド
    protected LocalDate getNow() {
        return LocalDate.now();
    }

この時点でテスト実行して、Greenを保っていることを確認します。

テスト実行に影響があるメソッドをテストクラス内でOverrideする

まず、テストクラス内にあるDeliveryDateクラスを変数化します。変数化してもテスト実行がGreenになることを確認します。

//DeliveryDateTest.java
    @Test
    void _小の月の月末になる場合のテスト() {
        DeliveryDate deliveryDate = new DeliveryDate(); //DeliveryDateクラスの変数化
        LocalDate actualDate = deliveryDate.getDeliveryDate();
        assertEquals(LocalDate.of(2020,9,30),actualDate);
    }

続いて、DeliveryDateクラスをFakeDeliveryDateクラスに変更します。この時点では、コンパイルエラーが発生します。

//DeliveryDateTest.java
    @Test
    void _小の月の月末になる場合のテスト() {
        DeliveryDate deliveryDate = new FakeDeliveryDate(); //FakeDeliveryDateクラスに変更
        LocalDate actualDate = deliveryDate.getDeliveryDate();
        assertEquals(LocalDate.of(2020,9,30),actualDate);
    }

コンパイルエラーの原因となっているFakeDeliveryDateクラスをインナークラスで作成します*9。この時点でテスト実行してGreenになることを確認します。

//DeliveryDateTest.java
    @Test
    void _小の月の月末になる場合のテスト() {
        DeliveryDate deliveryDate = new FakeDeliveryDate();
        LocalDate actualDate = deliveryDate.getDeliveryDate();
        assertEquals(LocalDate.of(2020,9,30),actualDate);
    }

    //FakeDeliveryDateクラスの作成
    private class FakeDeliveryDate extends DeliveryDate {
    }

インナークラスの中身にOverrideしたgetNow()メソッドを記述します。まずは、DeliveryDateクラスにあるgetNow()を継承します。この時点でテスト実行してGreenになることを確認します。

//DeliveryDateTest.java内にあるインナークラスFakeDeliveryDate
    private class FakeDeliveryDate extends DeliveryDate {
        //getNowメソッドを継承する
        @Override
        protected LocalDate getNow() {
            return super.getNow();
        }
    }

続いて、getNow()メソッドの中身を書き換えます。まずは、DeliveryDateクラスにあるgetNow()メソッドの中身をそのままコピペします*10。この時点でテスト実行してGreenになることを確認します。

//DeliveryDateTest.java内にあるインナークラスFakeDeliveryDate
    private class FakeDeliveryDate extends DeliveryDate {
        @Override
        protected LocalDate getNow() {
            //DeliveryDateクラスのgetNow()メソッドの中身をそのままコピペして持ってくる
            return LocalDate.now();
        }
    }

最後に、getNow()メソッドの中身を日付指定の処理に変更します。この時点でテスト実行してGreenになることを確認します*11

//DeliveryDateTest.java内にあるインナークラスFakeDeliveryDate
    private class FakeDeliveryDate extends DeliveryDate {
        @Override
        protected LocalDate getNow() {
            return LocalDate.of(2020,9,13); //日付指定の処理に変更
        }
    }

このように変更していくことで、実行日付に依存しているgetNow()メソッドのみを排除した形でテスト実行できるようにできました。

テストメソッド内で日付指定できるようにする

現在の状態でテスト実行ができるようになりましたが、このままだと必ず2020年9月13日が指定されてしまいます。そこでテストメソッドごとに日付指定できるようにします。

まずは日付指定ができるようなsetDate()メソッドを作成します。この時点ではsetDate()メソッドが存在しないためコンパイルエラーが発生します*12

//DeliveryDateTest.java内にある小の月の月末テスト
    @Test
    void _小の月の月末になる場合のテスト() {
        FakeDeliveryDate deliveryDate = new FakeDeliveryDate();
        deliveryDate.setDate(LocalDate.of(2020,9,13)); //setDate()メソッドの呼び出し。現在はsetDate()メソッドがないためコンパイルエラーが発生
        LocalDate actualDate = deliveryDate.getDeliveryDate();
        assertEquals(LocalDate.of(2020,9,30),actualDate);
    }

次に、setDate()メソッドを実装します。コンパイルエラーが解消され、テスト実行すると再びGreenになります。

//DeliveryDateTest.java
    @Test
    void _小の月の月末になる場合のテスト() {
        FakeDeliveryDate deliveryDate = new FakeDeliveryDate();
        deliveryDate.setDate(LocalDate.of(2020,9,13));
        LocalDate actualDate = deliveryDate.getDeliveryDate();
        assertEquals(LocalDate.of(2020,9,30),actualDate);
    }

    private class FakeDeliveryDate extends DeliveryDate {
        @Override
        protected LocalDate getNow() {
            return LocalDate.of(2020,9,13);
        }

        //setDate()メソッドの作成
        public void setDate(LocalDate date) {
        }
    }

setDate()メソッドの引数として入っているdateをフィールド変数に入れます。この時点でもテスト実行がGreenになり続けていることを確認します。

//DeliveryDateTest.java内にあるインナークラスFakeDeliveryDate
    private class FakeDeliveryDate extends DeliveryDate {
        LocalDate date;
        @Override
        protected LocalDate getNow() {
            return LocalDate.of(2020,9,13);
        }

        public void setDate(LocalDate date) {
            this.date = date; //引数をフィールド変数dateに代入する
        }
    }

フィールド変数のdateをgetNow()メソッド時に返すようにします。getNow()メソッドの処理が変わりましたが、この時点でもテスト実行がGreenになり続けていることを確認します。

//DeliveryDateTest.java内にあるインナークラスFakeDeliveryDate
    private class FakeDeliveryDate extends DeliveryDate {
        LocalDate date;
        @Override
        protected LocalDate getNow() {
            return date; //returnする値を変更する
        }

        public void setDate(LocalDate date) {
            this.date = date;
        }
    }

このように変更することで、テストケースの中で日付を設定してテスト実行することができるようになりました。

別のテストケースでもテスト実行ができるようにする

ここまで変更により、「_小の月の月末になる場合のテスト」のテストケースを日付を指定しつつ実行できるようになりました。

ここからは同様に、「_大の月の月末になる場合のテスト」のテストケースも実行できるようにします。

まずは「_大の月の月末になる場合のテスト」のコメントアウトを外します。この時点では(テスト実行日が大の月でない限り)テスト実行するとRedになります。

//DeliveryDateTest.java内にある大の月の月末テスト
    @Test
    void _大の月の月末になる場合のテスト() {
        LocalDate actualDate = new DeliveryDate().getDeliveryDate();
        assertEquals(LocalDate.of(2020,10,31),actualDate);
    }

次に、「_小の月の月末になる場合のテスト」のテストケースをコピペしてsetDate()メソッドの日付を(2020,10,13)に変更します。この時点でテスト実行してGreenになることを確認します。

//DeliveryDateTest.java内にある大の月の月末テスト
    @Test
    void _大の月の月末になる場合のテスト() {
        FakeDeliveryDate deliveryDate = new FakeDeliveryDate();
        deliveryDate.setDate(LocalDate.of(2020,10,13)); //先ほど作成したsetDate()メソッドを呼び出す
        LocalDate actualDate = deliveryDate.getDeliveryDate();
        assertEquals(LocalDate.of(2020,10,31),actualDate);
    }

これにより、「_大の月の月末になる場合のテスト」もテストができるようになりました。

おわりに:今回のレガシーコードのリファクタリングでのポイント

ここまでのリファクタリングによって、テストケースを注入することができました。

このコードはまだまだリファクタリングの余地があります。

  • 小の月と大の月に関するテストケースの拡充(実装コード上、それぞれの小の月を指定したif文で分岐していますが、テストコード上は9月しか小の月をテストできていません)
  • うるう年の場合のテストケースの拡充及びロジックの追加(本来のうるう年のルールはもっと複雑です)
  • もともとの実装コードはロングメソッドになっているので、責務を分割するためのメソッド化
  • 配送日が次月になるテストケースを拡充
  • 実装コードのロジックが間違っている箇所の修正(テストケースを拡充すると分かりますが、ロジックがそもそも間違っています)

テストケースを注入できたことで、最初よりも上記のリファクタリングも行いやすくなっているでしょう。

今回のポイントは以下の2点です。

  • まずはテストケースを1つでも作成して、とりあえずテスト実行をしてみる(頑張って、たくさんのテストを作ろうとしなくてOK)
  • テストしづらい部分を見つけて、テスト実行への障壁を排除する

また、今回に限らず、リファクタリングをする際に重要な点として、常に対象のテストを実行し続けていて、Greenでキープしたままリファクタリングを行っていることにも注目してください。

宣伝

実は本記事は、現在執筆中の技術同人誌『テストコードの注入から始めるレガシーコードのリファクタリング』の第1章の前半部分をブログ記事にしたものになります。

同人誌の中では、もっと複雑な題材も扱っていきたいと思いますので、興味がある方は下記のleanpubページから購入をお願いします*13 。同ページに無料サンプル版も載せています。

leanpub.com

また、Boothでも公開しています。価格はLeanpubよりも高くなります。ご了承ください。

nihonbuson.booth.pm

Boothのページ内にあるサンプル版はこちら。

nihonbuson.booth.pm

さらに、JaSST'21 Tokyoでは、今回よりも少し複雑な題材を用いて、ライブコーディング形式での発表をしました。

詳細は下記ページをご確認ください。

www.jasst.jp

JaSST Tokyoで発表した内容の詳しい解説も、書籍には掲載しています。

*1:ここでは書籍『レガシーコード改善ガイド』( https://www.amazon.co.jp/dp/B01AN97W08 )の考え方を引用し、「テストコードがないコード=レガシーコード」としています

*2:本記事を作成するにあたり、TDDBC Onlineの皆さんのアドバイスが大いに役立ちました。アドバイスをいただき本当にありがとうございました!

*3:このコードを見て「なんだよ!全然複雑じゃないじゃん!現場のコードはもっと複雑だから、こんな記事を見ても意味がないや」と感じた人もいるかもしれません。しかし、本記事で伝えたいことは「複雑なコードに対処すること」ではなく、「テストコードがないものに対処すること」です。テストコードがない状態に対して、一歩でも先に進む方法を本記事では紹介します。

*4:TDDの1周目のRedで行うことと同じ手順です

*5:テスト実行した結果、成功を「Green」、失敗を「Red」と言います。本記事では以降「Green」「Red」と表記します。詳しくは書籍『テスト駆動開発』( https://www.amazon.co.jp/dp/B077D2L69C/ )を参考にしてください。

*6:Redになったとしても、悲観する必要はありません。「設定が足りないことを教えてくれた」と、テストコードを書くことによって新情報を得られたことを喜ぶべきです。

*7:JaSST'18 Tokyo招待講演「私が経験したソフトウェアテストの変遷」 www.jasst.jp

*8:TDDではリファクタリングで変更するたびにGreenになっていることを確認します。レガシーコードのリファクタリングでも同様に、変更するたびにGreenになっていることを確認します。

*9:存在しないクラスをあたかも存在するかのように記述し、コンパイルエラーの内容に従ってクラスを作成するという今回の手順は、TDDでもよく行われるやり方です。

*10:まずはコピペをすることで、論理的にはテスト失敗に変化することはないはずです

*11:今回はステップを丁寧にして、まずは実装コードをコピペして、そこからロジックを変更していっています。これにより、「呼び出しの変更に対応」→「ロジックの変更に対応」と、気にする部分を分けてリファクタリングしています。

*12:TDDサイクルのRedの時の手順と似たようなやり方です

*13:「素早く出版できる」「一度購入してもらえれば、Updateがあれば購入者に無料で提供できる」「購入者がある程度自由に価格を決められる」という点で、私が本を出版するときはLeanpubを選択しちゃいます。Paypalに慣れてない人が多いかもしれないけど。