[Golang]テスタブルな現在時刻取得処理を作る


はじめに

backendサービスを作っていると大抵どこかしらで現在時刻time.Now()を取り扱うことになると思います。
しかしtime.Now()の値は、刻一刻と変わる値なのでtime.Now()を使用する関数のtestを書く際には必ず
time.Now()の値を固定化する手段を必ず作っておかなければなりません。

Perlの場合はTest::MockTime、Rubyの場合はtimecop、Pythonの場合はFreezeGunのようなlibraryを使ってmockしますが
Goの場合このtime.Now()を固定化するためにどうすればよいのか幾つか方法を試したので紹介します。

monkey.Patch()

まずは monkey.Patch()を使う方法。

注意:一応書いておきますが、おすすめの方法ではありません。
monkey.Patch()は実行時に実行ファイルを書き換えて、mockしたい関数を呼び出すイリーガルなツールです。
既に実装されているプロダクトコードにテストを書かなきゃいけないなど
どうしても困った時に使うという感覚で使用してください。


まずは、適当にtime.Now()を使うコードを書いてみます。

package main

import (
    "context"
    "time"
)

type Hoge struct {
    Now time.Time
}

func GetHoge(ctx context.Context) *Hoge {
    now := time.Now().UTC()
    return &Hoge{
        Now: now,
    }
}

model用のstructに現在時刻を突っ込んで後々の処理に使うよくある実装ですね。
これのテストをmonkey.Patch()で書いてみます。

package main

import (
    "context"
    "testing"
    "time"

    "bou.ke/monkey"
    "github.com/stretchr/testify/assert"
)

func TestGetHoge(t *testing.T) {
    t.Run("time mock test use monkey", func(t *testing.T) {
        ctx := context.Background()
        mockTime := time.Date(2020, 4, 3, 1, 2, 3, 123456000, time.UTC)
        patch := monkey.Patch(time.Now, func() time.Time { return mockTime })
        defer patch.Unpatch()
        expected := &Hoge{Now: mockTime}

        actual := GetHoge(ctx)
        assert.Equal(t, expected, actual)
    })
}

紹介する方法の中で使い方は一番簡単。

        patch := monkey.Patch(time.Now, func() time.Time { return mockTime })
        defer patch.Unpatch()

この2行を書くだけで……あら不思議!
プロダクトコードに書かれたtime.Now()の戻り値をmockした関数の値に置き換えてくれます。
プロダクトコードを全然変更していないのに関数の挙動が変化する文字通りモンキーパッチです。
defer patch.Unpatch()を忘れると当該のテスト通過した後もpatchされたままになるので注意してください。

ちなみに、沢山monkey.Patch()を仕込みたいときは一つずつdefer patch.Unpatch()しなくてもdefer monkey.UnpatchAll()で全てunpatch出来ます。

        monkey.Patch(fuga.GetFuga, func() *fuga.Fuga { return &fuga.Fuga{} })
        monkey.Patch(Piyo.GetPiyo, func() *piyo.Piyo { return &piyo.Piyo{} })
        monkey.Patch(time.Now, func() time.Time { return mockTime })
        defer monkey.UnpatchAll()

flextime.Now()

お次はflextimeを使う方法。

どういう発想のものなのか作者のブログ見るのが良いです。
https://songmu.jp/riji/entry/2020-01-19-flextime.html

flextimeはテストコードの中で現在時刻を切り替えるためのライブラリです。Sleep時に実際に時間を止めずに時間が経過したように見せかける機能もあります。

つまり、PerlのTest::MockTimeやRubyのtimecop的なことをしたいわけですが、Goだとグローバルに関数の挙動を切り替えるといったことはできないため、利用にあたってはtimeパッケージで使っている関数を、flextimeパッケージに切り替える必要があります。

要するにtime.Now()を使わずに、mock出来る独自のflextime.Now()を使うという方法。
見て分かる通りプロダクトコードの書き換えが必須になります。

こちらも紹介はしますが使うかどうかは自己責任でお願いします。

何故ならグローバルで定義されたmock出来るflextimeを使う場合は
flextime.Now()を使っている関数Aとは別の関数Bでダミーの値をSetするような処理を作り
その関数BでflextimeをRestoreするのを忘れるバグがあった場合
関数Bとはまるで関係ない関数Aで取得した時刻が意図せずダミーの時刻になってしまう可能性があるからです。


先ほどのコードをflextime.Now()を使うように書き換えます。

package main

import (
    "context"
    "time"

    "github.com/Songmu/flextime"
)

type Hoge struct {
    Now time.Time
}

func GetHoge(ctx context.Context) *Hoge {
    now := flextime.Now().UTC()
    return &Hoge{
        Now: now,
    }
}

次にテストコード
flextime.Set()を使用してflextimeの内部時刻をmockの時刻に置き換えます。

package main

import (
    "context"
    "testing"
    "time"

    "github.com/Songmu/flextime"
    "github.com/stretchr/testify/assert"
)

func TestGetHoge(t *testing.T) {
    t.Run("time mock test use flextime", func(t *testing.T) {
        ctx := context.Background()
        mockTime := time.Date(2020, 4, 3, 1, 2, 3, 123456000, time.UTC)
        restore := flextime.Set(mockTime)
        defer restore()
        expected := &Hoge{Now: mockTime}

        actual := GetHoge(ctx)
        assert.Equal(t, expected, actual)
    })
}

現在時刻とmock時刻はflextime内で管理されているので
flextime.Set()するだけでflextime.Now()の時刻を固定化することが出来ます。

先ほどの話なのですが
flextime.Set()をプロダクトコード内で使用してdefer restore()を忘れるバグがあると
全てのflextime.Now()を使用する関数で必ず時刻が同じになってしまうという重大なバグを生む可能性があります。

Context.Value に現在時刻を保持する

さっきからオススメしないオススメしないばかり言って結局何をオススメするんだという話ですが
個人的にはこの「Context.Valueに現在時刻を保持する」案をオススメします。

要するにtime.Now()を関数内で行うからmockに困るのであって
現在時刻を関数の引数で渡してしまえばいいじゃないという話。

time.Now()を行う場所はhandlerを呼ぶ前のmiddlewareレイヤーに移して
middleware内でtime.Now()の結果をContext.Valueに格納します。
そして現在時刻を使用したい時はContext.Valueから現在時刻を取得するためのutilを使用するようにします。


まずは現在時刻をcontext.Valueに格納したり、context.Valueから現在時刻を取得するutilを用意します。

package timeutil

import (
    "context"
    "time"
)

const CtxFreezeTimeKey = "freeze_time_key"

// context.Valueから現在時刻を取得する関数
func Now(ctx context.Context) time.Time {
    return ctx.Value(CtxFreezeTimeKey).(time.Time)
}

// context.Valueに現在時刻を格納する関数
func SetNow(ctx context.Context) context.Context {
    return context.WithValue(ctx, CtxFreezeTimeKey, time.Now())
}

// テスト用にmockしたいtime.Timeをcontext.Valueに格納する関数
func MockNow(ctx context.Context, mockTime time.Time) context.Context {
    return context.WithValue(ctx, CtxFreezeTimeKey, mockTime)
}

middlewareの実装は場合によって異なるので詳しくは書きませんが
例えばgPRC Serverの場合はServer Chainに以下のようなInterceptorを挟みます。

func UnaryServerInterceptor() grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (_ interface{}, err error) {
        ctx = timeutil.SetNow(ctx)
        return handler(ctx, req)
    }
}

次に現在時刻を実際に使用する部分

package main

import (
    "context"
    "time"

    "sample-timemock/timeutil"
)

type Hoge struct {
    Now time.Time
}

func GetHoge(ctx context.Context) *Hoge {
    now := timeutil.Now(ctx).UTC()
    return &Hoge{
        Now: now,
    }
}

time.Now()timeutil.Now(ctx)に置き換えています。
timeutil.Now(ctx)flextime.Now()とは異なり引数のcontext.Contextから値を取り出すだけなので
utilでtime.Now()をセットする処理がバグってるとか以外であれば他の関数の影響を受けることはありません。
まあそもそもutilでtime.Now()をセットする処理がバグってる場合はutilのtestがpassしないはずです。

では上記のテストを書いていきます。

package main

import (
    "context"
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
    "sample-timemock/timeutil"
)

func TestGetHoge(t *testing.T) {
    t.Run("time mock test use flextime", func(t *testing.T) {
        mockTime := time.Date(2020, 4, 3, 1, 2, 3, 123456000, time.UTC)
        mockCtx := timeutil.MockNow(context.Background(), mockTime)
        expected := &Hoge{Now: mockTime}

        actual := GetHoge(mockCtx)
        assert.Equal(t, expected, actual)
    })
}

やってることはcontext.Valueにmockのtime.Timeを突っ込んで引数で渡しているだけで非常にシンプル。
検証してきた中ではモンキーパッチも使わずグローバルなtime領域も汚さない一番良さそうな実装だと思います。

・・・ただし

現在時刻をmiddlewareでセットする実装の場合は、requestでhandlerが呼ばれた時点での現在時刻をセットするため
そのスコープ内の現在時刻が必ず同じになります。

例えば、独自でログ出力処理を書いていたりする場合

[2020-04-03 16:10:23.134] CreateHoge()
[2020-04-03 16:10:23.134] CreateFuga()
[2020-04-03 16:10:23.134] CreatePiyo()
[2020-04-03 16:10:23.134] GetHoge()

みたいに全部同じ時刻になってしまうので、time.Now()全てをtimeutil.Now(ctx)に置き換えるかどうかは考えた方が良いですね。