とあるお兄さんの雑記

基本的に技術系の内容を書きますが、何を書くかは私の気分です。

見よう見まねでテスト駆動開発〜その2〜

テスト駆動開発(TDD)の第二弾です。
TDDとは何か、なぜやろうと思ったのかなどについては、前回の記事をご覧頂ければと思います。

第一弾の記事

環境

macOS BigSur メモリ16G
Java 11.0.9.1
JUnit5 
IntelliJ IDEA CE 2020.3

今回作成するもの

今回作成するものは、Javaを一通り学んだ(とは言ってもメソッドを学んだぐらいの段階ですが)大学生の私が深夜テンションで作った、西暦と月と日にちを与えればその日の曜日を計算できる機能です。
(勘の良い方は元ネタが何かわかるかと思います。サマーウォーズの序盤の序盤で出てきた奴です。「サマーウォーズ モジュロ演算」とかでググれば出てきます。)

今回作るこの機能を、この記事ではモジュロ演算システムと呼ぶようにし、テスト駆動で開発してみることにします。

前回まで

前回は西暦の上2桁を取得するという非常にシンプルなテストだけを書いて終わりました。テストクラス(ModuloTest.java)と、実際のクラス(Modulo.java)は以下のようになっています。

ModuloTest.java

package modulo;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class ModuloTest {
    @Test
    public void testFirst2Digits(){
        Modulo modulo = new Modulo();
        assertEquals(20, modulo.getFirst2Digits(2021));
    }
}

Modulo.java

package modulo;

public class Modulo {
    private int first2Digits;

    public Modulo(){
        first2Digits = 0;
    }

    public int getFirst2Digits(int year){
        first2Digits = year / 100;
        return first2Digits;
    }
}

そして、現在のTODOリストは以下のようになっています。

TODOリスト


  • 2021年4月1日を入力として与えると、木曜日を出力する
  • 2021年の上二桁を取得
  • 2021年の下二桁を取得
  • 月を取得
  • 月が1月または2月だった場合は、それぞれ前年の13月、14月に変換
  • 日にちを取得
  • チェラーの公式を使って曜日を出力する

現在取り組んでいるものは太字、終わったものに関しては取り消し線で表すことにします。

今回はTODOリストから「2021年の下二桁を取得する」を取り出してこの機能を作っていくことにします。
(西暦下二桁の取得だけじゃなく、月や、日にちの取得もまとめてやっちゃってもいいんじゃないの?と思われるかもしれません。ただ今回は、小さいステップ(スモールステップ)でやろうと思っていますので、多少煩雑に思われるかもしれませんが、お付き合い願います。)

西暦の下二桁を取得する

まずはテストから書いていきましょう!

ModuloTest.java

package modulo;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class ModuloTest {
    @Test
    public void testGetMethod(){
        Modulo modulo = new Modulo();
        assertEquals(20, modulo.getFirst2Digits(2021));
        assertEquals(21, modulo.getLast2Digits(2021)); //追加
    }
}

メソッド名はtestFirst2DigitsからtestGetMethodに変更しました。というのも、下2桁のテストを加えたことで、上2桁だけのテストではなくなったためです。

テストを走らせてみます。すると当たり前ですがテストはこけます。Modulo.javaにgetLast2Digitsメソッドを作っていないためです。このテストを通すようにするため、Modulo.javaを以下のように書いてみます。

Modulo.java

package modulo;

public class Modulo {
    private int first2Digits;

    public Modulo(){
        first2Digits = 0;
    }

    public int getFirst2Digits(int year){
        first2Digits = year / 100;
        return first2Digits;
    }

    public int getLast2Digits(int year){
        return 21;
    }
}

これでテストは通るようになります。ですが、このままだとどの西暦を入れても、 21という数字を返すことになるため、西暦の下二桁を返すようにリファクタリングします。

Modulo.java

package modulo;

public class Modulo {
    private int first2Digits;
    private int last2Digits;

    public Modulo(){
        first2Digits = 0;
        last2Digits = 0;
    }

    public int getFirst2Digits(int year){
        first2Digits = year / 100;
        return first2Digits;
    }

    public int getLast2Digits(int year){
        last2Digits = year % 100;
        return last2Digits;
    }
}

これでテストは通るようになりました!TODOリストを更新します。

TODOリスト


  • 2021年4月1日を入力として与えると、木曜日を出力する
  • 2021年の上二桁を取得
  • 2021年の下二桁を取得
  • 月を取得
  • 月が1月または2月だった場合は、それぞれ前年の13月、14月に変換
  • 日にちを取得
  • チェラーの公式を使って曜日を出力する

あとは月の取得、日にちの取得を順にやっていけばいいのですが、ここでは西暦の上2桁、下2桁の取得とやり方はほぼ同じなので省略します。

少し立ち止まる

さて、西暦の下二桁、上二桁、月、日にちを取得して返す機能のテストクラスと実際のクラスは以下のようになりました。

Modulo.java

package modulo;

public class Modulo {
    private int first2Digits;
    private int last2Digits;
    private int month;
    private int day;

    public Modulo(){
        first2Digits = 0;
        last2Digits = 0;
        month = 0;
        day = 0;
    }

    public int getFirst2Digits(int year){
        first2Digits = year / 100;
        return first2Digits;
    }

    public int getLast2Digits(int year){
        last2Digits = year % 100;
        return last2Digits;
    }

    public int getMonth(int month){
        this.month = month;
        return month;
    }

    public int getDay(int day){
        this.day = day;
        return day;
    }
}

ModuloTest.java

package modulo;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class ModuloTest {
    @Test
    public void testGetMethod(){
        Modulo modulo = new Modulo();
        assertEquals(20, modulo.getFirst2Digits(2021));
        assertEquals(21, modulo.getLast2Digits(2021));
        assertEquals(4, modulo.getMonth(4));
        assertEquals(1, modulo.getDay(1));
    }
}

で、私としては、なんか違和感を覚えてしまって...。
Modulo.javaの各getメソッドは値を取得するだけのものなので、引数を受け取るのはおかしいんじゃない?と思いました。加えて、オブジェクトを作った時に引数として、西暦、月、日にちを渡した方がいいのでは?と考えました。
つまり、テストで書くとこうなります。

ModuloTest.java

package modulo;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class ModuloTest {
    @Test
    public void testGetMethod(){
        Modulo modulo = new Modulo(2021, 4, 1);
        assertEquals(20, modulo.getFirst2Digits());
        assertEquals(21, modulo.getLast2Digits());
        assertEquals(4, modulo.getMonth());
        assertEquals(1, modulo.getDay());
    }
}

このテストを通すために、以下のようにModulo.javaのクラスを以下のように書きます。 Modulo.java

package modulo;

public class Modulo {
    private int first2Digits;
    private int last2Digits;
    private int year;
    private int month;
    private int day;

    public Modulo(int year, int month, int day){
        this.first2Digits = 0;
        this.last2Digits = 0;
        this.year = year;
        this.month = month;
        this.day = day;
    }

    public int getFirst2Digits(){
        first2Digits = year / 100;
        return first2Digits;
    }

    public int getLast2Digits(){
        last2Digits = year % 100;
        return last2Digits;
    }

    public int getMonth(){
        this.month = month;
        return month;
    }

    public int getDay(){
        this.day = day;
        return day;
    }
}

Modulo.javaのgetメソッドは大分シンプルになったように感じます。this.month = month;などの重複部分があるので、そこだけ消しましょう。

Modulo.java

package modulo;

public class Modulo {
    private int first2Digits;
    private int last2Digits;
    private int year;
    private int month;
    private int day;

    public Modulo(int year, int month, int day){
        this.first2Digits = 0;
        this.last2Digits = 0;
        this.year = year;
        this.month = month;
        this.day = day;
    }

    public int getFirst2Digits(){
        first2Digits = year / 100;
        return first2Digits;
    }

    public int getLast2Digits(){
        last2Digits = year % 100;
        return last2Digits;
    }

    public int getMonth(){
        return month;
    }

    public int getDay(){
        return day;
    }
}

今回少し立ち止まったのは、TDDなどとは関係ないかもしれませんが、もっとよく書けるんじゃないか?という考えは大事に思えます(元々の原因は西暦と月と日にちの入力をどうするかを全く考えていなかったためですが)。

月が1月または2月だった場合は、それぞれ前年の13月、14月に変換する機能の実装

現在のTODOリストは以下のようになっています。

TODOリスト


  • 2021年4月1日を入力として与えると、木曜日を出力する
  • 2021年の上二桁を取得
  • 2021年の下二桁を取得
  • 月を取得
  • 月が1月または2月だった場合は、それぞれ前年の13月、14月に変換
  • 日にちを取得
  • チェラーの公式を使って曜日を出力する

TODOリストから「月が1月または2月だった場合は、それぞれ前年の13月、14月に変換」をとってきて、これを作業中にします。が、この作業、よくみると以下の2つに分けられそうです。

  • 月が1もしくは2の時に西暦を-1する
  • 月が1もしくは2の時に12を足す

これら2つをTODOリストに書いて、まず「月が1もしくは2の時に西暦を-1する」機能を実装することにしましょう。

TODOリスト


  • 2021年4月1日を入力として与えると、木曜日を出力する
  • 2021年の上二桁を取得
  • 2021年の下二桁を取得
  • 月を取得
  • 月が1月または2月だった場合は、それぞれ前年の13月、14月に変換
  • 月が1もしくは2の時に西暦を-1する
  • 月が1もしくは2の時に12を足す
  • 日にちを取得
  • チェラーの公式を使って曜日を出力する

月が1もしくは2の時に西暦を-1する

まずは例によってテストから書いていきます。

ModuloTest.java

package modulo;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class ModuloTest {
    @Test
    public void testGetMethod(){
        Modulo modulo = new Modulo(2021, 4, 1);
        assertEquals(20, modulo.getFirst2Digits());
        assertEquals(21, modulo.getLast2Digits());
        assertEquals(4, modulo.getMonth());
        assertEquals(1, modulo.getDay());
    }

    @Test
    public void testGetFirstAndLast2Digits(){
        Modulo modulo = new Modulo(2021, 1, 1);
        assertEquals(20, modulo.getFirst2Digits());
        assertEquals(20, modulo.getLast2Digits());
    }
}

テストがこけたので、このテストを通すようにします。

Modulo.java

    public int getLast2Digits(){
        if(month == 1 || month == 2){
            year = year - 1;
        }
        last2Digits = year % 100;
        return last2Digits;
    }

これでテストは通るようになりました。が、getLast2Digits()の中に、月が1月または2月なのかそれ以外の月なのかという処理をしています。できれば、getLast2Digits()の中の処理は西暦の下2桁だけを返すようにしたいです。

というわけで、設計として正しいかどうかは置いておき、新しい機能として月が1月または2月だったら、戻り値にyear - 1を、それ以外の月であれば戻り値にyearを設定するメソッドを作成します。

Modulo.java

    public int getLast2Digits(){
        year = calcYearFromMonth(month);
        last2Digits = year % 100;
        return last2Digits;
    }
〜省略〜

    private int calcYearFromMonth(){
        if(month == 1 || month == 2){
            return year - 1;
        }else{
            return year;
        }
    }

このように書けばテストが通り、月が1月または2月の時の西暦の計算ができるようになりました。TODOリストを更新します。

TODOリスト


  • 2021年4月1日を入力として与えると、木曜日を出力する
  • 2021年の上二桁を取得
  • 2021年の下二桁を取得
  • 月を取得
  • 月が1月または2月だった場合は、それぞれ前年の13月、14月に変換
  • 月が1もしくは2の時に西暦を-1する
  • 月が1もしくは2の時に12を足す
  • 日にちを取得
  • チェラーの公式を使って曜日を出力する

コラム:設計としての正しさ?

先ほど月が1月もしくは2月だった場合の西暦を計算する機能を作成しましたが、設計として正しいかと言われると正しくはないでしょう。

例えば、増田先生の著書である現場で役立つシステム設計の原則では、intなどの基本データ型を使うのはあまりよろしくないとされています(29ページから)。

というのも、intはマイナス21億からプラス21億までの値を扱うことができます。しかし、実際の業務ではこんなに大きな値を使う可能性は低いですし、思わぬ障害が混入する原因になります。ましてや、月であれば 1 12まで、日にちであれば 1 31までしか必要ありません。むやみやたらにintなどを使うのはやめたほうがいいでしょう。

そうなると、なぜ今回はやらないのかというと面倒だからです。それと今回のメインはあくまでテスト駆動開発だということで、その中にシステム設計の話なども盛り込むとややこしくなる可能性が高いのと、テスト駆動開発とシステム設計の話をうまくまとめられる自信がないため今回の記事ではスキップさせていただきます。

ちなみに、増田先生の著書はこちらです。エンジニア1〜3年目の方はほぼ必読、ベテランエンジニアの方でも役立つ内容が多いため非常におすすめの本です。

現場で役立つシステム設計の原則

コラム:privateメソッドはテストしなくていいのか?

先ほど月が1月もしくは2月だった場合の西暦を計算する機能を作成しましたが、そのメソッドはpublicではなく、privateでした。privateで宣言するとそのクラス内でしか参照することができません。つまり、publicで書かない限りテストを書くことはできません。

じゃあ全てpublicで書かないといけないかというと、そうではないように思います。実際、月の計算は外部から参照することなく内部のみで呼び出しているので、わざわざpublicにする必要はありません。
じゃあどうすれば良いのか。

こちらに関してはテスト駆動開発の翻訳者である和田さんがブログで回答されています。

t-wada.hatenablog.jp

詳しい内容は上記ブログを参考にして貰えれば良いのですが、結論だけ書けば、ほとんどのプライベートメソッドはパブリックメソッド経由でテストを行うことができるため、プライベートメソッドのテストを書く必要は無いです。

テストの追加

先ほど、月が1もしくは2の時に西暦を-1するという機能を作りました。作ったはいいのですが、西暦が仮に2000年の時、ちゃんと1999年が返ってくるでしょうか?一旦このことをTODOリストに書いて、それに取り組むことにします。

TODOリスト


  • 2021年4月1日を入力として与えると、木曜日を出力する
  • 2021年の上二桁を取得
  • 2021年の下二桁を取得
  • 月を取得
  • 月が1月または2月だった場合は、それぞれ前年の13月、14月に変換
  • 月が1もしくは2の時に西暦を-1する
  • 月が1もしくは2の時に2000年を-1し、1999年が取得できるかを確認
  • 月が1もしくは2の時に12を足す
  • 日にちを取得
  • チェラーの公式を使って曜日を出力する



テストを書いて確認してみましょう。

ModuloTest.java

〜略〜
    @Test
    public void testGetFirstAndLast2Digits2(){
        Modulo modulo = new Modulo(2000, 1, 1);
        assertEquals(19, modulo.getFirst2Digits());
        assertEquals(99, modulo.getLast2Digits());
    }
〜略〜

見事にテストがこけました。どうやら西暦の上2桁が合っていないようです。calcYearFromMonthメソッドを西暦の下二桁でしか呼んでいないからでしょう。加えて、calcYearFromMonthメソッドの中の処理も怪しいです。yearという変数に代入してしまっているため、常に値が変わってしまいます。
そこで、一旦ここはlastYearという変数を作りましょう。lastYearという変数を使う方がバグの発生などを減らせます。

Modulo.java

〜略〜
    private int first2Digits;
    private int last2Digits;
    private int year;
    private int lastYear; //追記
    private int month;
    private int day;

    public Modulo(int year, int month, int day) {
        this.first2Digits = 0;
        this.last2Digits = 0;
        this.year = year;
        this.lastYear = this.year - 1; //追記
        this.month = month;
        this.day = day;
    }
〜略〜

このように書きました。加えて、月が1もしくは2の時に返す西暦を次のように書き換えます。

Modulo.java

〜略〜
    private int calcYearFromMonth(){
        if(month == 1 || month == 2){
            return lastYear;
        }else{
            return year;
        }
    }
〜略〜

また、getFirst2DigitsgetLast2Digitsメソッドないで受け取る変数もyearとして書き換えるのではなく、新しくnewYearという変数名にしましょう(newYearという変数は新年を思い浮かべるため、あまりよろしくないのですが、他にいい案が思いつかなかったため一旦newYearとして突き進みます)。

Modulo.java

〜略〜
    private int first2Digits;
    private int last2Digits;
    private int year;
    private int lastYear; //追記
    private int month;
    private int day;

    public Modulo(int year, int month, int day) {
        this.first2Digits = 0;
        this.last2Digits = 0;
        this.year = year;
        this.lastYear = this.year - 1; //追記
        this.month = month;
        this.day = day;
    }
〜略〜
    public int getFirst2Digits(){
        newYear = calcYearFromMonth();
        first2Digits = newYear / 100;
        return first2Digits;
    }

    public int getLast2Digits(){
        newYear = calcYearFromMonth();
        last2Digits = newYear % 100;
        return last2Digits;
    }
〜略〜

これで、テストが通るようになりました。TODOリストを更新します。

TODOリスト


  • 2021年4月1日を入力として与えると、木曜日を出力する
  • 2021年の上二桁を取得
  • 2021年の下二桁を取得
  • 月を取得
  • 月が1月または2月だった場合は、それぞれ前年の13月、14月に変換
  • 月が1もしくは2の時に西暦を-1する
  • 月が1もしくは2の時に2000年を-1し、1999年が取得できるかを確認
  • 月が1もしくは2の時に12を足す
  • 日にちを取得
  • チェラーの公式を使って曜日を出力する

月が1もしくは2の時に12を足す(13月、14月にする)

TODOリスト


  • 2021年4月1日を入力として与えると、木曜日を出力する
  • 2021年の上二桁を取得
  • 2021年の下二桁を取得
  • 月を取得
  • 月が1月または2月だった場合は、それぞれ前年の13月、14月に変換
  • 月が1もしくは2の時に西暦を-1する
  • 月が1もしくは2の時に2000年を-1し、1999年が取得できるかを確認
  • 月が1もしくは2の時に12を足す
  • 日にちを取得
  • チェラーの公式を使って曜日を出力する

「月が1もしくは2の時に西暦を-1する」という機能は作ったので、次に「月が1もしくは2の時に12を足す」という機能を作ります。例によってテストから。

ModuloTest.java

package modulo;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class ModuloTest {
    @Test
    public void testGetMethod(){
        Modulo modulo = new Modulo(2021, 4, 1);
        assertEquals(20, modulo.getFirst2Digits());
        assertEquals(21, modulo.getLast2Digits());
        assertEquals(4, modulo.getMonth());
        assertEquals(1, modulo.getDay());
    }

    @Test
    public void testGetFirstAndLast2Digits(){
        Modulo modulo = new Modulo(2021, 1, 1);
        assertEquals(20, modulo.getFirst2Digits());
        assertEquals(20, modulo.getLast2Digits());
    }

    @Test
    public void testGetFirstAndLast2Digits2(){
        Modulo modulo = new Modulo(2000, 1, 1);
        assertEquals(19, modulo.getFirst2Digits());
        assertEquals(99, modulo.getLast2Digits());
    }


    @Test
    public void testGetMonth(){
        Modulo modulo = new Modulo(2021, 2, 1);
        assertEquals(14, modulo.getMonth());
    }
}

当たり前ですがテストは通りません。というわけで、これを通すようにします。

Modulo.java

〜略〜
    private int calcMonth(){
        if(month == 1 || month == 2){
            return month + 12;
        }else{
            return month;
        }
    }
〜略〜

これで一応通りますが、これもメソッド内でmonthの値を書き換えてしまっています。これをやると先ほども書いたように思わぬバグを招いていしまいますので、新しい変数を用意してそれを返すようにしましょう。
Enumを使うという方法もありますが、ここはより簡単にJANUARYFEBRUARYという定数を用意して、予め13と14という値を入れてmonthが1月、2月の時はその定数を返す方法にしてみましょう。

Modulo.java

〜略〜
    private final int JANUARY = 13;
    private final int FEBRUARY = 14;
〜略〜
    private int calcMonth(){
        if(month == 1){
            return JANUARY;
        }else if(month == 2){
            return FEBRUARY;
        }else{
            return month;
        }
    }
〜略〜

テストが通ったことでリファクタリングも無事に終わりました。TODOリストを更新しましょう。

TODOリスト


  • 2021年4月1日を入力として与えると、木曜日を出力する
  • 2021年の上二桁を取得
  • 2021年の下二桁を取得
  • 月を取得
  • 月が1月または2月だった場合は、それぞれ前年の13月、14月に変換
  • 月が1もしくは2の時に西暦を-1する
  • 月が1もしくは2の時に2000年を-1し、1999年が取得できるかを確認
  • 月が1もしくは2の時に12を足す
  • 日にちを取得
  • チェラーの公式を使って曜日を出力する

残るはチェラーの公式を使って曜日を計算するところですね。長くなりましたので、今回はここまでとします。

ここまでのソースコード

Modulo.java

package modulo;


public class Modulo {
    private int first2Digits;
    private int last2Digits;
    private int year;
    private int lastYear;
    private int newYear;
    private int month;
    private final int JANUARY = 13;
    private final int FEBRUARY = 14;
    private int day;

    public Modulo(int year, int month, int day) {
        this.first2Digits = 0;
        this.last2Digits = 0;
        this.year = year;
        this.lastYear = this.year - 1;
        this.newYear = 0;
        this.month = month;
        this.day = day;
    }

    public int getFirst2Digits(){
        newYear = calcYearFromMonth();
        first2Digits = newYear / 100;
        return first2Digits;
    }

    public int getLast2Digits(){
        newYear = calcYearFromMonth();
        last2Digits = newYear % 100;
        return last2Digits;
    }

    public int getMonth(){
        month = calcMonth();
        return month;
    }

    public int getDay(){
        return day;
    }

    private int calcYearFromMonth(){
        if(month == 1 || month == 2){
            return lastYear;
        }else{
            return year;
        }
    }

    private int calcMonth(){
        if(month == 1){
            return JANUARY;
        }else if(month == 2){
            return FEBRUARY;
        }else{
            return month;
        }
    }
}

ModuloTest.java

package modulo;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class ModuloTest {
    @Test
    public void testGetMethod(){
        Modulo modulo = new Modulo(2021, 4, 1);
        assertEquals(20, modulo.getFirst2Digits());
        assertEquals(21, modulo.getLast2Digits());
        assertEquals(4, modulo.getMonth());
        assertEquals(1, modulo.getDay());
    }

    @Test
    public void testGetFirstAndLast2Digits(){
        Modulo modulo = new Modulo(2021, 1, 1);
        assertEquals(20, modulo.getFirst2Digits());
        assertEquals(20, modulo.getLast2Digits());
    }

    @Test
    public void testGetFirstAndLast2Digits2(){
        Modulo modulo = new Modulo(2000, 1, 1);
        assertEquals(19, modulo.getFirst2Digits());
        assertEquals(99, modulo.getLast2Digits());
    }


    @Test
    public void testGetMonth(){
        Modulo modulo = new Modulo(2021, 2, 1);
        assertEquals(14, modulo.getMonth());
    }
}

参考

テスト駆動開発

現場で役立つシステム設計の原則

t-wada.hatenablog.jp