読者です 読者をやめる 読者になる 読者になる

JUnitで現在時刻が関わるテストを解いてみた

java tdd test

これであなたもテスト駆動開発マスター!?和田卓人さんがテスト駆動開発問題を解答コード使いながら解説します~現在時刻が関わるテストから、テスト容易性設計を学ぶ #tdd に書いてある問題がUnitTestを書いていく上での教材にとても良さそうだったので、自分のベンチマークがてら書いてみました。

【仕様1】 「現在時刻」に応じて、挨拶の内容を下記のようにそれぞれ返す機能を作成したい。

まずは問題1をシンプルに問いてみました。

記事でも言及されてますけど、時間を扱った問題は普通にテストを書くことが難しいです。 環境に依存したデータのでテスト毎に結果が変わってしまうからです。他にも外部のWebサイトとか、別のシステムとかそういったパラメータが入ると同じ理由で普通にテストできません。

なので、まず考えたことはDate型を引数に渡して、テストコード側で現在値を設定できるようにすることです。 こういうのは依存性を注入できるように実装するのが王道ですしね。

ただ、Javaで任意のDate型を初期化するのは面倒なので、いったんintでhourとminuteを受け取るテストとメソッドを定義。

    @Test
    public void greeterWithMorning() {
        Greeter greeter = new Greeter();
        assertThat(greeter.greet(5, 0), is("おはようございます"));
        assertThat(greeter.greet(11, 59), is("おはようございます"));
    }

後はベタにRed => Greenのサイクルを回して仕様を満たしていきました。 その後、下記のようにint型ではなく日付型を受け取るメソッドを定義し直します。

    @Test
    public void greeterWithDate() {
        Greeter greeter = new Greeter();
        assertThat(greeter.greet(time(6, 0)), is("おはようございます"));
        assertThat(greeter.greet(time(12, 0)), is("こんにちは"));
        assertThat(greeter.greet(time(23, 0)), is("こんばんは"));
    }

境界値のテストとかはint型のケースで試してるので、書いてません。timeメソッドはさすがにCalendarの初期化を毎回書くのが面倒なので独自定義。 実際の開発ならDateUtils的な何かを入れてるはずなので、それを使うべきだと思います。

問題1はこんな感じ。intの実装は最終的には消してDateの実装だけにしたほうが、実装の詳細が残らないからいいかなぁ、とも思ったんですが、問題2でリファクタリング必要そうなのは分かってたので、いったんこのままで。全体のコードはこちらのgist.

【仕様2】 「現在時刻」と「ロケール」に応じて、挨拶の内容を下記のようにそれぞれ返す機能を作成したい。

同じ時間でもロケールが日本語なのか英語なのかで挨拶を変える課題です。単純にインタフェースを定義するとこんな感じでしょうか。

    assertThat(greeter.greet(time(6, 0), Locale.JAPAN), is("おはようございます"));
    assertThat(greeter.greet(time(6, 0), Locale.ENGLISH), is("Good Morning"));

パッと見の感想として、引数が2つになっています。となるとテストパターンがn * m というやんちゃそうな数になり、めんどそうです。 なので、要素を単純化するため問題1の機能を拡張せずに、まず以下のようなリファクタリングをしました。

  • 朝、昼、夜を表すenumのTime{Morning, Afternoon, Evening}を定義
  • greetから時間帯判定の条件分岐を抽出してgetTimeメソッドに。挨拶文を直接返すのではなく、Time enumを返す
  • Time enumを引数にとってメッセージを返すgetMessageを定義

上記のリファクタリングをした上でgetTime, getMessageにもテストを書きます。 これによって時間帯を判定するメソッドと、時間帯に応じたメッセージを返すメソッドを分離できました。 公開属性をpublicにしようかとも思ったのですが、テスト以外で使われることも無さそうなのでパッケージデフォルトにしてあります

では、次に新規のテストを追加します。getMessageを時間帯だけではなく、ロケールも受け取るように拡張しましょう。

    assertThat(greeter.getMessage(Time.MORNING, Locale.JAPAN), is("おはようございます"));
    assertThat(greeter.getMessage(Time.EVENING, Locale.ENGLISH), is("Good Evening"));

あとは例のごとくテストと実装のサイクルを回します。

    assertThat(greeter.greet(time(6, 0), Locale.JAPAN), is("おはようございます"));
    assertThat(greeter.greet(time(12, 0), Locale.JAPAN), is("こんにちは"));
    assertThat(greeter.greet(time(23, 0), Locale.JAPAN), is("こんばんは"));
    assertThat(greeter.greet(time(6, 0), Locale.ENGLISH), is("Good Morning"));
    assertThat(greeter.greet(time(12, 0), Locale.ENGLISH), is("Good Afternoon"));
    assertThat(greeter.greet(time(23, 0), Locale.ENGLISH), is("Good Evening"));

続いてGreeter#greetも同様の拡張をしてテスト。この時、時間帯判定ロジックは弄っていないので、テストを変える必要は無いです。 getMessageとgetTimeの2つのメソッドに分割することでそれぞれのメソッドの責務が分解されて、テストパターンも圧縮されたのでたぶんいい感じのはず。

個人的には、ひとつのメソッド毎に分岐がひとつの方がテストは書きやすいので、なるべくそうしたい。TDDするとめんどくさいから、そういった構造になりやすいのが良いです。テスト対象コードと、この時点のテストコードはこちら.

テストのリファクタリング

続いてテストのリファクタリング。記事を読んでたらParameterized Test という考え方があるらしいので、試してみることに。 私がよくやってしまう...Test01, ...Test02という残念な名前を付けなくても良いようにテストロジックとデータを分離させる記法です。

    @ToString
    @AllArgsConstructor
    public static class GetTimeParameter {

        int hour;
        int minute;
        Time expected;
    }

    @DataPoints
    public static GetTimeParameter[] moning() {
        GetTimeParameter[] dataset = {
            new GetTimeParameter(5, 0, Time.MORNING),
            new GetTimeParameter(11, 59, Time.MORNING)
        };
        return dataset;
    }

    @DataPoints
    public static GetTimeParameter[] afternoon() {
        GetTimeParameter[] dataset = {
            new GetTimeParameter(12, 0, Time.AFTERNOON),
            new GetTimeParameter(17, 59, Time.AFTERNOON)
        };
        return dataset;
    }

    @DataPoints
    public static GetTimeParameter[] evening() {
        GetTimeParameter[] dataset = {
            new GetTimeParameter(18, 0, Time.EVENING),
            new GetTimeParameter(23, 59, Time.EVENING),
            new GetTimeParameter(4, 59, Time.EVENING)
        };
        return dataset;
    }

    @Theory
    public void getTimeWith(GetTimeParameter parameter) {
        Greeter greeter = new Greeter();
        assertThat(parameter.toString(), greeter.getTime(parameter.hour, parameter.minute), is(parameter.expected));
    }

JUnit4 ではParameterized Test を支援する機能として Parameterized アノテーションやTheories アノテーションがあります。

エラー検出時の可読性はParameterizedが良いですが、それ以外のすべての面でTheoriesが使いやすかったので、こっちを使用。リファクタリング後のテストはこちら.

本気で可読性とエラー表示の見やすさを狙うなら大人しくSpock使えという感じのようなので、今度そっちも検証してみます。

まとめ

今回、直接カレンダー型呼びましたけど、プロダクトで使うなら結合テストのしやすさも考えて、SystemDateとか言う名前の現在時刻を返すクラスを作って、必要に応じて設定ファイルとかで値を制御できるようにします。

で、それを今の実装の引数なしメソッドの実装にする感じです。そうしておくことで、ブラウザ等からのテストでも時間固定ができるので。

UTもその機能で賄うことも不可能ではありませんが、テストコードがめんどくさくなるので、今回のように設計で解決したほうが基本無難な認識です。

それにしても、元記事はいろんな観点で解説があって非常に参考になりました。問題も良かったし。 まだまだTDD(というかここまで来ると詳細設計)に関して未熟なので自分も精進しないと、年の締めくくりに思った次第。

それでは、Happy Hacking!