2016/04/07

Android: 現在時刻を固定するテストとTestRule

Fixed current time.

時間に依存したAPIの振る舞いをテストする場合, 現在時刻を固定すればテストをRepeatableに保てます.
現在時刻を固定する次の2通りの方法を実装しました.

  1. java.timeを使った方法(Java8以降)
  2. java.utilを使った方法(Java7以前)

テストの際に現在時刻を固定化する下準備として, 現在時刻を返すAPIをラップしたメソッドを用意します.

public static long now() {
    return <現在時刻を返すAPI>
}

テストの際には固定時刻を返すように振る舞いを変更してやれば, このAPIに依存しているモジュールからするとあたかも現在時刻が固定化されているように見えます.

目的達成のためには, now()メソッドが常にダミーのepoch timeを返すように振る舞いを差し替える仕組みを用意する必要があります.
今回はJava7以前で使えるアプローチと, Java8以降で使えるアプローチを実装しました.

Java8

Java8からはjava.timeパッケージが追加され, 日付関係のユーティリティが強化されました(JSR-310).
現在時刻のepoch timeを取得するためにSystem.currentTimeMillis()を使用する代わりにclock.millis()が使えます.

  • Clockクラスを使用します.

Clockが指す時刻はClock.fixedClock.offsetを使って自由に変更できます.

現在時刻を取得するAPIにClockを使うことで, 現在時刻を固定化することも可能です.

下記はClockを使った現在時刻のepoch timeを返すメソッドと, それを固定化するメソッドです.

// 現在時刻のepoch timeを返す
public static long now() {
    return clock.millis();
}

// 現在時刻を固定する
@VisibleForTesting
protected static void fixedCurrentTime(@NonNull Clock clock) {
    clock = clock;
}

// 現在時刻の固定を解除する
@VisibleForTesting
protected static void tickCurrentTime() {
    clock = Clock.systemUTC();
}

固定したい日時を設定したClockfixedCurrentTime()に渡せば, now()が返す値が固定されます.
固定化を解除したい場合はtickCurrentTimeを呼びます.

Java7

ClockはJava8で導入されたjava.timeパッケージが提供するAPIです.
そのため, Java7以前の環境では別の方法をとるか, JSR-310のバックポートライブラリを使う必要があります.

バックポートライブラリには下記が使えます.

今回はこれらを使用せず別の方法をとりました.
Java7以前でもライブラリの追加無しでとれる方法です.

まず, Clockに代わる現在時刻のepoch timeを返すインタフェースNowProviderを用意します.
現在時刻を返すにはSystem.currentTimeMillis()を呼び出します.

interface NowProvider {
    long now();
}

private static NowProvider systemCurrentTimeProvider = System::currentTimeMillis;

// nowで返すepoch timeを決定するプロバイダ
private static NowProvider nowProvider = systemCurrentTimeProvider;

// 現在時刻のepoch timeを返す
public static long now() {
    return nowProvider.now();
}

次に現在時刻を返すNowProviderを差し替える仕組みを用意します.

// 現在時刻を固定する
@VisibleForTesting
protected static void fixedCurrentTime(NowProvider provider) {
    nowProvider = provider;
}

// 現在時刻の固定を解除する
@VisibleForTesting
protected static void tickCurrentTime() {
    nowProvider = systemCurrentTimeProvider;
}

固定したい日時を設定したNowProviderfixedCurrentTime()に渡せば, now()が返す値が固定されます.
固定化を解除したい場合はtickCurrentTimeを呼びます.

TestRule

現在時刻を固定するには固定と固定解除のメソッドを対で呼び出す必要があります.
固定解除のコードが実行されないと他のテストケースに意図しない影響を及ぼします.

テストケースごとにtry-finallyで時刻固定-解除のロジックを書くのも骨が折れます.
そこでJUnitTest Ruleを使って簡便化します.

// Java8 or after
public class Jsr310TimeTest {

    @Rule
    public Jsr310TimeRule jsr310TimeRule = new Jsr310TimeRule();

    @Test
    @Now("2000-01-01T00:00:00Z")
    public void テスト() throws Exception {
        assertThat(Jsr310Time.now()).isEqualTo(
                Jsr310Time.parseIso8601Z("2000-01-01T00:00:00Z"));
    }
}

// Java7 or before
public class LegacyTimeTest {

    @Rule
    public LegacyTimeRule legacyTimeRule = new LegacyTimeRule();

    @Test
    @Now("2000-01-01T00:00:00Z")
    public void テスト() throws Exception {
        assertThat(LegacyTime.now()).isEqualTo(
                LegacyTime.parseIso8601Z("2000-01-01T00:00:00Z"));
    }
}

Jsr310TimeRule/LegacyTimeRuleはテストメソッドがNowでアノテートされているとテスト開始時に現在時刻を固定し, テスト終了時に解除します.
@Nowを宣言するだけで現在時刻が固定化されて便利です.

蛇足

テストクラスに含まれる全てのテストで現在時刻を固定したいのであれば, NowアノテーションでElementType.Typeをサポートし, テストランナーを@ClassRuleで宣言すれば実現できます.
ただ, テストケースで現在時刻が固定されていることに気付き辛くなるため実装しませんでした.