とあるお兄さんの雑記

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

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

テスト駆動開発(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 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

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;
        }
    }
}

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

TODOリスト


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

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

チェラーの公式を使って曜日を出力する

チェラーの公式を使って計算しましょう。例によってテストから。

ModuloTest.java

〜略〜
    @Test
    public void testZeller(){
        Modulo modulo = new Modulo(2021, 4, 1);
        assertEquals("木", modulo.calcZeller());
    }
〜略〜

テストはこけるので、これを通すように作っていきます。

Modulo.java

〜略〜
    public String calcZeller(){
        return "木";
    }
〜略〜

通りました。あとはこれをリファクタリングしていきます。

ここで、チェラーの公式は以下のようなものでした。ここでチェラーの公式で使われている文字と、実際のコードの変数との対応関係は以下になります。

 \displaystyle h = \left( q + \left\lfloor \frac{26(m+1)}{10} \right\rfloor  + K  + \left\lfloor \frac{K}{4} \right\rfloor + \left\lfloor \frac{J}{4} \right\rfloor  -2J \right) \bmod   7

文字 変数
 q day
 m month
 K last2Digits
 J first2Digits

そして、 hに対する数字と曜日の関係は以下になります。

 h 曜日
0 土曜日
1 日曜日
2 月曜日
3 火曜日
4 水曜日
5 木曜日
6 金曜日

以上の情報を元に作っていきます。

Modulo.java

package modulo;

import java.util.HashMap;
import java.util.Map;

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;
    private int h; //追記

    public Modulo(int year, int month, int day) {
        this.first2Digits = 0;
        this.last2Digits = 0;
        this.h = 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;
    }

    public String calcZeller(){
        h = (day + 26*(month+1)/10 + last2Digits + last2Digits/4 + first2Digits/4 + 5*first2Digits) % 7; // 追記
        return "木";
    }

    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;
        }
    }
}

ここで、チェラーの公式で計算を行っているところで、- 2*first2Digitsと書くべきところを+ 5*first2Digitsと書いています。これは、多くのコンピュータの環境では負数の剰余を保証しません。(ってWikipediaに書いてありました。ちなみに、上記のチェラーの公式を使うのであれば、西暦1582年以降じゃないと無理っぽいですね。)

何を言っているかというと、余りを計算する場合、余りの値としては正の値が出てきて欲しいのですが、Javaに限らず他のプログラミング言語やコンピュータの環境では負の値を出してしまいます。例えば、

 -2 \div 7の余りは 5

という風に、余りの値を正として出して欲しいのですが、Javaだと

 -2 \div 7の余りは -2

と余りの値は負の値となってしまいます。チェラーの公式に当てはめるのであれば、正の値を出す必要があります。

そこで、整数の合同関係を使って計算することで、マイナスの値をプラスの値に変換することができます。

意外とこの合同関係って使うらしく、以下の動画でも使われています。6分あたりから合同式をうまく使って、テトリスの左回転を計算しています(なかなか面白いので、最初から見るのをお勧めします)。

www.youtube.com



最後に hの数値を元に曜日を出します。ここはif文でもswitch文でもいいのですが、せっかくなので、Mapを使ってみましょう。 hの数値をkeyとして、曜日を返すようなMapです。

Modulo.java

package modulo;

import java.util.HashMap;
import java.util.Map;

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;
    private int h;
    private Map<Integer, String> map;

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

  //追記
        map = new HashMap<>(){
            {
                put(0, "土");
                put(1, "日");
                put(2, "月");
                put(3, "火");
                put(4, "水");
                put(5, "木");
                put(6, "金");
            }
        };
    }

    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;
    }

    public String calcZeller(){
        first2Digits = getFirst2Digits();//追記
        last2Digits = getLast2Digits(); //追記

        h = (day + (26*(month+1))/10 + last2Digits + last2Digits/4 + first2Digits/4 + 5*first2Digits) % 7;
        return map.get(h); //変更
    }

    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;
        }
    }
}

mapという変数を宣言しました(変数名のセンスの無さよ...)。これで hをkeyとして曜日を取得することができます。
さて、これでテストを実行すると全て通ることが確認できるかと思います。

TODOリストを更新しましょう。ついでに、入力できる西暦も1582年〜というのも追加しておきましょう。

TODOリスト


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

西暦の入力を1582年以降に制限する

上記で紹介しているチェラーの公式を使うには、1582年以降(グレゴリオ暦)出ないとちゃんと計算できません。ユリウス歴や紀元前も計算に含めればいい練習になるとは思うのですが、面倒になってきたのでやりません。

さて、例によってテストから。

ModuloTest.java

〜略〜
    @Test
    public void testYearLimit(){
        Modulo modulo = new Modulo(1581, 4, 1);
        assertEquals("西暦は1582年以降を入力してください", modulo.calcZeller());
    }
〜略〜

テストは落ちるので、これを通すようにします。

Modulo.java

〜略〜
    public String calcZeller(){
        if(year < 1582){
            return "西暦は1582年以降を入力してください";
        }else{
            first2Digits = getFirst2Digits();
            last2Digits = getLast2Digits();

            h = (day + (26*(month+1))/10 + last2Digits + last2Digits/4 + first2Digits/4 + 5*first2Digits) % 7;
            return map.get(h);
        }
    }
〜略〜

これでテストは通るようになりました。TODOリストを更新します。また、上記テストでは2021年の4月をテストしていますが、念の為、1月、2月の時もちゃんと出るか、また、西暦が2000年で月が1月、2月の場合とそうでない月の場合で正しい曜日が出るかをテストとして追加して確認してみましょう。

TODOリスト


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

テスト項目の追加

2021年1月30日を入力として与えると、土曜日を出力する

TODOリストをみやすくしましょう。すでに終わったタスクは消します。

TODOリスト


  • 2021年1月30日を入力として与えると、土曜日を出力する
  • 2000年7月1日を入力として与えると、土曜日を出力する
  • 2000年2月1日を入力として与えると、火曜日を出力する

ModuloTest.java

〜略〜
    @Test
    public void testZeller2(){
        Modulo modulo = new Modulo(2021, 1, 30);
        assertEquals("土", modulo.calcZeller());
    }
〜略〜

上記テストを追加しテストを実行すると、テストは通りませんでした。まじか...。ここまできてテストが通らないのは辛い...。
さて、原因を探ってみると、Modulo.javacalcZellerメソッドの中身で使われている月がmonthのままでした。さらに探るとgetMonthメソッドがテストの時しか呼び出されておらず、さらにmonthという変数に代入してしまっています。

ModuloTest.java

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


ここで落ち着いて考えてみると、今のままだと、calcZellerメソッドからgetMonthメソッドを呼び、さらにgetMonthメソッドからcalcMonthメソッドを呼び出すことになってしまい、なんだか二度手間です。

それよりもcalcZellerメソッドからcalcMonthメソッドの処理を呼んで、newMonthという新しい変数名に代入し、newMonthをチェラーの公式の中で使うようにしたほうが良さそうです。

つまり、

Modulo.java

package modulo;

import java.util.HashMap;
import java.util.Map;

public class Modulo {
    private int first2Digits;
    private int last2Digits;
    private int year;
    private int lastYear;
    private int newYear;
    private int month;
    private int newMonth;//追記
    private final int JANUARY = 13;
    private final int FEBRUARY = 14;
    private int day;
    private int h;
    private Map<Integer, String> map;

    public Modulo(int year, int month, int day) {
        this.first2Digits = 0;
        this.last2Digits = 0;
        this.h = 0;
        this.year = year;
        this.lastYear = this.year - 1;
        this.newYear = 0;
        this.month = month;
        this.newMonth = 0; //追記
        this.day = day;

        map = new HashMap<>(){
            {
                put(0, "土");
                put(1, "日");
                put(2, "月");
                put(3, "火");
                put(4, "水");
                put(5, "木");
                put(6, "金");
            }
        };
    }

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

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

    public int getMonth(){//中身の処理を変更
        if(month == 1){
            return JANUARY;
        }else if(month == 2){
            return FEBRUARY;
        }else{
            return month;
        }
    }

    public int getDay(){
        return day;
    }

    public String calcZeller(){
        if(year < 1582){
            return "西暦は1582年以降を入力してください";
        }else{
            first2Digits = getFirst2Digits();
            last2Digits = getLast2Digits();
            newMonth = getMonth(); //追記

            h = (day + (26*(newMonth+1))/10 + last2Digits + last2Digits/4 + first2Digits/4 + 5*first2Digits) % 7;
            return map.get(h);
        }
    }

    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;
        }
    }
}

こんな感じです。これでテストを走らせると全てグリーンになりました。あとはModulo.javacalcMonthメソッドは使わなくなったので、それを消してリファクタリング終了です。TODOリストも更新します。

TODOリスト


  • 2021年1月30日を入力として与えると、土曜日を出力する
  • 2000年7月1日を入力として与えると、土曜日を出力する
  • 2000年2月1日を入力として与えると、火曜日を出力する



2000年7月1日を入力として与えると、土曜日を出力する

次のTODOリストに残っているタスクを確認し、取り組みましょう。

TODOリスト


  • 2021年1月30日を入力として与えると、土曜日を出力する
  • 2000年7月1日を入力として与えると、土曜日を出力する
  • 2000年2月1日を入力として与えると、火曜日を出力する



ModuloTest.java

〜略〜
    @Test
    public void testZeller3(){
        Modulo modulo = new Modulo(2000, 7, 1);
        assertEquals("土", modulo.calcZeller());
    }
〜略〜

テストを走らせると、全て通ることを確認できます。他に特にやることはないので、TODOリストを更新して終了です。

TODOリスト


  • 2021年1月30日を入力として与えると、土曜日を出力する
  • 2000年7月1日を入力として与えると、土曜日を出力する
  • 2000年2月1日を入力として与えると、火曜日を出力する

2000年2月1日を入力として与えると、火曜日を出力する

TODOリスト


  • 2021年1月30日を入力として与えると、土曜日を出力する
  • 2000年7月1日を入力として与えると、土曜日を出力する
  • 2000年2月1日を入力として与えると、火曜日を出力する



ModuloTest.java

〜略〜
    @Test
    public void testZeller4(){
        Modulo modulo = new Modulo(2000, 2, 1);
        assertEquals("火", modulo.calcZeller());
    }
〜略〜

テストを走らせると、全て通ることを確認できます。他に特にやることはないので、TODOリストを更新して終了です。



TODOリスト


  • 2021年1月30日を入力として与えると、土曜日を出力する
  • 2000年7月1日を入力として与えると、土曜日を出力する
  • 2000年2月1日を入力として与えると、火曜日を出力する

これでTODOリストのタスク全てを消化できました。これで作りたかったモジュロ演算システムは完成です。お疲れ様でした。

TDDを一通りやってみて

最後はテストが通らないという事件もありましたが、無事最後までやり遂げることができました。途中、私が疲れてしまったところもあり雑に書いてしまった部分もあるかと思います。
とはいえ、ある程度TDDの一通りの手順は理解いただけたのでは無いでしょうか?

私自身、実際にTDDで開発してみて、変数名やメソッド名の付け方、クラスを分けるなど、どの部分がスキル的に足りないかというのを見つめ直すいい機会になりました。足りない部分に関してはまた一から勉強していければと考えています。

今回、TDDでの開発を3回に渡って書き記してきましたが、ここで書いた内容はTDD全体の一部でしかありません。本気でTDDを学びたいのであれば、書籍や他の記事を参考にしつつ、ご自身の手で書いていくことが一番身につきやすいと思います。

また、TDD全てを真似する必要はありませんし、人それぞれ向き不向きがあるかと思います。TDDや他の開発手法のいいとこ取りをしつつ、自分なりの開発手法を身につけていけばいいの ではないでしょうか?

今回の記事が皆さんのお役に立てれば幸いです。

ソースコード

Modulo.java

package modulo;

import java.util.HashMap;
import java.util.Map;

public class Modulo {
    private int first2Digits;
    private int last2Digits;
    private int year;
    private int lastYear;
    private int newYear;
    private int month;
    private int newMonth;
    private final int JANUARY = 13;
    private final int FEBRUARY = 14;
    private int day;
    private int h;
    private Map<Integer, String> map;

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

        map = new HashMap<>(){
            {
                put(0, "土");
                put(1, "日");
                put(2, "月");
                put(3, "火");
                put(4, "水");
                put(5, "木");
                put(6, "金");
            }
        };
    }

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

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

    public int getMonth(){
        if(month == 1){
            return JANUARY;
        }else if(month == 2){
            return FEBRUARY;
        }else{
            return month;
        }
    }

    public int getDay(){
        return day;
    }

    public String calcZeller(){
        if(year < 1582){
            return "西暦は1582年以降を入力してください";
        }else{
            first2Digits = getFirst2Digits();
            last2Digits = getLast2Digits();
            newMonth = getMonth();

            h = (day + (26*(newMonth+1))/10 + last2Digits + last2Digits/4 + first2Digits/4 + 5*first2Digits) % 7;
            return map.get(h);
        }
    }

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

ModuloTest.java

package modulo;

import org.junit.jupiter.api.Disabled;
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());
    }

    @Test
    public void testZeller(){
        Modulo modulo = new Modulo(2021, 4, 1);
        assertEquals("木", modulo.calcZeller());
    }

    @Test
    public void testYearLimit(){
        Modulo modulo = new Modulo(1581, 4, 1);
        assertEquals("西暦は1582年以降を入力してください", modulo.calcZeller());
    }

    @Test
    public void testZeller2(){
        Modulo modulo = new Modulo(2021, 1, 30);
        assertEquals("土", modulo.calcZeller());
    }

    @Test
    public void testZeller3(){
        Modulo modulo = new Modulo(2000, 7, 1);
        assertEquals("土", modulo.calcZeller());
    }

    @Test
    public void testZeller4(){
        Modulo modulo = new Modulo(2000, 2, 1);
        assertEquals("火", modulo.calcZeller());
    }
}