はじめに
本記事はテスト駆動開発 Advent Calendar 2020 最終日の記事です。このアドベントカレンダー はスカスカなので、今からでもテスト駆動開発 の経験談 などをエントリーしてもらえると嬉しいです!
目次
テスト駆動開発 (以下、TDD)を学び始めた人が、現場でぶつかる壁の一つは「レガシーコード*1 なのでTDDが使えない」だと思います。
「レガシーコードをリファクタリング したいけど、リファクタリング するためのテストコードがない」「テストコードを書きたいけど、レガシーコードなのでテストコードが書きづらい」というジレンマに陥りがちです。
そこで本記事では、TDDの考え方を活用しつつ、まずは1つだけでもテストコードを注入する ことで、レガシーコードに立ち向かっていきます。*2
今回の題材
レガシーコードになりがちなコードの1つとして、現在時刻に依存しているなど、テストしづらい状況であるコードがあります。
例えば、以下のようなコード*3 です。
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行目で現在日時を挿入し、その日時の月末を配送日として設定します。しかし、現在日時が下旬や年末の場合は、配送日が次月に設定されます。
本章ではこのコードに対してリファクタリング を行っていきます。
最初のテストコード
さて、このようなコードに対して、どのようにテストケースを追加していけば良いでしょうか。
最初に作るべきテストコードでは「とりあえず動くテスト」を目指します。
今回の場合は以下のようなコードをとりあえず作ってみましょう。
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class DeliveryDateTest {
@Test
void _配送日のテスト() {
new DeliveryDate();
assertEquals(1 ,1 );
}
}
ただ、DeliveryDateクラスを呼び出しただけ*4 です。
この状態でテスト実行をしてみましょう。もしもテスト実行してRedになった*5 場合、以下の2つのどちらかが原因でしょう。
テストフレームワーク の設定自体が間違っている
DeliveryDateクラスの呼び出しの際に必要な設定が足りない
このうち、他のクラスで同じテストフレームワーク を用いて動いていた場合は、1つ目の原因の可能性は限りなく低いでしょう。つまり、今回のテストコードを実行することで、そもそも実装コードを実行できるのか確認することができます*6 。
テスト実行してGreenになった場合、次はメソッドを呼び出します。
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class DeliveryDateTest {
@Test
void _配送日のテスト() {
new DeliveryDate().getDeliveryDate();
assertEquals(1 ,1 );
}
}
最初に書いたテストコードと同様、これもgetDeliveryDate()メソッドを実行できるのか確認することができます。
このようにテストコードを書くことで、実装コードを手軽に試すことができます。しかも一度書くと、自動で何度も実行することができます。
JaSST'18 Tokyo 招待講演*7 で、柴田芳樹さんは下記の発言をしていますが、今回の過程を写経すると実感ができると思います。
Unitテスト作成は自動でデバッグ している感覚
TDDは常に実装する感覚
仕様を理解してテストを作る
次は実装コードを見ながら期待値を当てはめていきます。
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日を期待値として設定しました。
別のテストケースを作る
続いて、実装コードを見て、別のテストケースを作ってみましょう。
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になります。
つまり、この方法だとうまくいかないことが分かります。このような場合、どうすれば良いのでしょうか。
依存関係を見つける
今回のテストがうまく行かない理由を考えてみましょう。
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()で現在の時刻を入れているため、テスト実行日時に依存してしまうのです。
この依存関係を削除する方法を考えましょう。
依存関係を削除する
日時に関する部分をメソッドとして切り出すことで依存関係を削除します。
実際にリファクタリング を行う手順を細かく区切って説明します。それぞれの手順ごとにテストは常に実行し続けた方が良いでしょう。
作業前の状態
作業前のテストコードの状態を改めて記載しておきます。
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);
}
}
なお、テストコードは「_大の月の月末になる場合のテスト」のテスト結果がRedになるため、今回はコメントアウト した状態で作業を始めます。
現在作成済みのテストケースが今回の狙いである依存関係部分の実装ロジックを通っているのか確認する
現在作成済みのテストケースを使って、カバレッジ を計測します。実行すると、下記画像のようになります。
カバレッジ 計測結果
上記画像のうち、赤囲み部分(今回の狙いの部分である6行目)に注目してください。
行番号(6)の右隣にある色を確認します。ここが緑色だと既存のテストケースが実装ロジックを通っていることを示しています。もしも、ここが赤色だとテストケースが実装ロジックを通っていないことを示しているので、赤色の結果のまま、その行のリファクタリング をするのは止めましょう。
テスト実行に影響があるロジックを切り出す
先ほどのカバレッジ 計測の結果、6行目はテストしている部分であることが分かったので、LocalDate localDate = LocalDate.now();の部分をメソッド化します。
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に変更します。
protected LocalDate getNow() {
return LocalDate.now();
}
この時点でテスト実行して、Greenを保っていることを確認します。
テスト実行に影響があるメソッドをテストクラス内でOverrideする
まず、テストクラス内にあるDeliveryDateクラスを変数化します。変数化してもテスト実行がGreenになることを確認します。
@Test
void _小の月の月末になる場合のテスト() {
DeliveryDate deliveryDate = new DeliveryDate();
LocalDate actualDate = deliveryDate.getDeliveryDate();
assertEquals(LocalDate.of(2020 ,9 ,30 ),actualDate);
}
続いて、DeliveryDateクラスをFakeDeliveryDateクラスに変更します。この時点では、コンパイル エラーが発生します。
@Test
void _小の月の月末になる場合のテスト() {
DeliveryDate deliveryDate = new FakeDeliveryDate();
LocalDate actualDate = deliveryDate.getDeliveryDate();
assertEquals(LocalDate.of(2020 ,9 ,30 ),actualDate);
}
コンパイル エラーの原因となっているFakeDeliveryDateクラスをインナークラスで作成します*9 。この時点でテスト実行してGreenになることを確認します。
@Test
void _小の月の月末になる場合のテスト() {
DeliveryDate deliveryDate = new FakeDeliveryDate();
LocalDate actualDate = deliveryDate.getDeliveryDate();
assertEquals(LocalDate.of(2020 ,9 ,30 ),actualDate);
}
private class FakeDeliveryDate extends DeliveryDate {
}
インナークラスの中身にOverrideしたgetNow()メソッドを記述します。まずは、DeliveryDateクラスにあるgetNow()を継承します。この時点でテスト実行してGreenになることを確認します。
private class FakeDeliveryDate extends DeliveryDate {
@Override
protected LocalDate getNow() {
return super .getNow();
}
}
続いて、getNow()メソッドの中身を書き換えます。まずは、DeliveryDateクラスにあるgetNow()メソッドの中身をそのままコピペします*10 。この時点でテスト実行してGreenになることを確認します。
private class FakeDeliveryDate extends DeliveryDate {
@Override
protected LocalDate getNow() {
return LocalDate.now();
}
}
最後に、getNow()メソッドの中身を日付指定の処理に変更します。この時点でテスト実行してGreenになることを確認します*11 。
private class FakeDeliveryDate extends DeliveryDate {
@Override
protected LocalDate getNow() {
return LocalDate.of(2020 ,9 ,13 );
}
}
このように変更していくことで、実行日付に依存しているgetNow()メソッドのみを排除した形でテスト実行できるようにできました。
テストメソッド内で日付指定できるようにする
現在の状態でテスト実行ができるようになりましたが、このままだと必ず2020年9月13日が指定されてしまいます。そこでテストメソッドごとに日付指定できるようにします。
まずは日付指定ができるようなsetDate()メソッドを作成します。この時点ではsetDate()メソッドが存在しないためコンパイル エラーが発生します*12 。
@Test
void _小の月の月末になる場合のテスト() {
FakeDeliveryDate deliveryDate = new FakeDeliveryDate();
deliveryDate.setDate(LocalDate.of(2020 ,9 ,13 ));
LocalDate actualDate = deliveryDate.getDeliveryDate();
assertEquals(LocalDate.of(2020 ,9 ,30 ),actualDate);
}
次に、setDate()メソッドを実装します。コンパイル エラーが解消され、テスト実行すると再びGreenになります。
@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 );
}
public void setDate(LocalDate date) {
}
}
setDate()メソッドの引数として入っているdateをフィールド変数に入れます。この時点でもテスト実行がGreenになり続けていることを確認します。
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をgetNow()メソッド時に返すようにします。getNow()メソッドの処理が変わりましたが、この時点でもテスト実行がGreenになり続けていることを確認します。
private class FakeDeliveryDate extends DeliveryDate {
LocalDate date;
@Override
protected LocalDate getNow() {
return date;
}
public void setDate(LocalDate date) {
this .date = date;
}
}
このように変更することで、テストケースの中で日付を設定してテスト実行することができるようになりました。
別のテストケースでもテスト実行ができるようにする
ここまで変更により、「_小の月の月末になる場合のテスト」のテストケースを日付を指定しつつ実行できるようになりました。
ここからは同様に、「_大の月の月末になる場合のテスト」のテストケースも実行できるようにします。
まずは「_大の月の月末になる場合のテスト」のコメントアウト を外します。この時点では(テスト実行日が大の月でない限り)テスト実行するとRedになります。
@Test
void _大の月の月末になる場合のテスト() {
LocalDate actualDate = new DeliveryDate().getDeliveryDate();
assertEquals(LocalDate.of(2020 ,10 ,31 ),actualDate);
}
次に、「_小の月の月末になる場合のテスト」のテストケースをコピペしてsetDate()メソッドの日付を(2020,10,13)に変更します。この時点でテスト実行してGreenになることを確認します。
@Test
void _大の月の月末になる場合のテスト() {
FakeDeliveryDate deliveryDate = new FakeDeliveryDate();
deliveryDate.setDate(LocalDate.of(2020 ,10 ,13 ));
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で発表した内容の詳しい解説も、書籍には掲載しています。