Goで時刻をモックする

はじめに

初めてこの話題を聞いたのは umeda.go #2 で発表してきた - kawaken’s blog でした(スライドは Goの時刻に関するテスト)。 その節は良いお話をありがとうございました。

この時点ではGoのアプリケーションのビルド時にGoの標準ライブラリーのコードを差し替えるのは別の用途で試して便利だったものの、時刻に関するテストは自分では試してませんでした。

その後、自分でも試そうと思い、紹介されていたライブラリー以外のライブラリーも調べてみたのでメモです。

Mocking time and testing event loops in Go [Dmitry Frank] の記事がとても詳しくて素晴らしいです。

こちらのサンプルコードをベースにしたもので検証してみました。

time.Now() だけならライブラリーを使わない選択もある

Goの時刻に関するテスト でも説明されていますが time.Now() を差し替えるだけならサードパーティのライブラリーを使わない選択もあり得ます。

Stubbing Time.Now() in golang - labs.yulrizka.com にも詳しい説明がありました。

時刻をモックすることの難しさ

Mocking time and testing event loops in Go [Dmitry Frank] の記事がとても参考になりました。

time.Now() を呼び出している個所の他に、 time.Timertime.Ticker のチャンネルを待っていたり time.Sleep() を呼んでいるコードがあると、時刻をモックするのは大変になってくるんですね。

モックで時刻を設定したあと時刻を進める際に、time.Timertime.Ticker のチャンネルに適宜時刻を送り、 time.Sleep() でブロックしていたコードを実行させるのが理想なのですが、なかなか難しいということがわかりました。

time.Ticker のチャンネルについて

モックで時刻を進めて time.Timer の発動時刻を過ぎていたらチャンネルに時刻を送るのは良いとして、 time.Ticker のほうは 2 回以上の tick を過ぎていたらどうするべきでしょうか。

そもそもモックではなく実時間の場合に time.Ticker のチャンネルから tick を受け取る前に次の tick が来てしまったらどうなるのでしょうか。

NewTicker の API ドキュメントには記載がありませんが、関数名の NewTicker のリンク をクリックして実装を見ると、以下のようなコメントとコードがありました。

	// Give the channel a 1-element time buffer.
	// If the client falls behind while reading, we drop ticks
	// on the floor until the client catches up.
	c := make(chan Time, 1)
	t := &Ticker{
		C: c,
		r: runtimeTimer{
			when:   when(d),
			period: int64(d),
			f:      sendTime,
			arg:    c,
		},
	}

バッファーサイズ 1 でチャンネルを作っていて、受け取り側が追い付かない場合は残りの tick は捨てられるとのことです。

src/time/sleep.go at go1.14.5sendTime 関数でチャンネルに時刻を送る処理の実装を見ると確かにそのようになっています。

func sendTime(c interface{}, seq uintptr) {
	// Non-blocking send of time on c.
	// Used in NewTimer, it cannot block anyway (buffer).
	// Used in NewTicker, dropping sends on the floor is
	// the desired behavior when the reader gets behind,
	// because the sends are periodic.
	select {
	case c.(chan Time) <- Now():
	default:
	}
}

モックの時刻を進めた後、チャンネルを受信するgoroutineを動かす必要がある

Mocking time and testing event loops in Go [Dmitry Frank] の “Letting other goroutines run” の項に詳しく書かれています。

Timer や Ticker のチャンネルを受信している goroutine がいる場合、発動前は受信待ちでブロックしている状態になっています。

モックの時計を指定の時刻まで進めると、 Timer や Ticker の発動時刻を過ぎる場合は、時刻が古いほうから順番に発動させるのが理想です。

このためにはチャンネルに時刻を送った後、チャンネルを受信待ちの goroutine を動かして受信してその後の処理を実行させる必要があります。

が goroutine をどのように実行するかは Go のランタイムに任されていて、明示的に実行を進める手段は用意されていません。

そこで github.com/benbjohnson/clock では回避策として time.Sleep(time.Millisecond) でスリープして他の goroutine を進めるという技が使われています。 ここはモックの時刻ではなく実時間でスリープするということです。

場合によっては 1ms スリープではうまく行かないケースもあるということで github.com/benbjohnson/clock の fork の github.com/dimonomid/clock ではモックの時計作成時に別の処理を行う関数が指定可能になっています。

サードパーティーのライブラリーを比較検討してみた

Mocking time and testing event loops in Go [Dmitry Frank] のサンプルコードをベースにしたテストコードを書いて試してみました。

github.com/Songmu/flextime

Goでテスト中に現在時刻を差し替えたりするflextimeというのを作った | おそらくはそれさえも平凡な日々 に作者の Songmu さんの紹介記事があります。

time パッケージからの移行

time パッケージの代わりに github.com/Songmu/flextime パッケージを使うように書き換えれば OK という手軽さが良いです。

flextime.Fix では設定した時刻に固定、 flextime.Set では指定した時刻から進んでいく出来るのも便利です。 flextime.Set の説明を見ると flextime.Set を呼んだ後 flextime.Sleep を呼ぶと実時間で待つことなくモックの時計を進められるとのことです。

また Clock インタフェースを実装してカスタムの時計を作って flextime.Switch() で差し替えられるのも良いです。

一方、内部では sync.Mutex で時計のグローバル変数を排他制御しており、 flextime.Now() など全ての関数実行時にロック取得・解放のオーバーヘッドがあるのが気になるところです。

個人的には production ではほぼオーバーヘッドなしで動く方式が良いです。 production では予め実時間のグローバル変数が設定されていて テストの場合は TestMain で初期化時に差し替えるようにし、各関数の実行時にはロックなしで参照できるのが私の希望する使い方です。 ライブラリー提供側の立場としては使い方が限定されるのは避けたいので今の API になっているのは理解できます。

テストコード

flextime v0.0.7 を使ったテストコードを hnakamur/go-mock-clock-experiment の using_github_com_Songmu_flextime ブランチ に置いています。

下記に引用します。

timeFormat はナノ秒まで出すと Go のランタイムの実行のゆらぎで値がずれてしまうので、あえてミリ秒単位にしています。

package main

import (
	"fmt"
	"log"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/Songmu/flextime"
	"github.com/kylelemons/godebug/diff"
)

const timeFormat = "2006-01-02T15:04:05.999Z07:00"

type strLog struct {
	b  strings.Builder
	mu sync.Mutex
}

func (l *strLog) Write(p []byte) (int, error) {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.b.Write(p)
}

func (l *strLog) String() string {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.b.String()
}

func TestMockTime(t *testing.T) {
	l := &strLog{}
	log.SetOutput(l)
	log.SetFlags(0)

	time0 := time.Date(2020, 5, 1, 0, 0, 0, 0, time.UTC)
	flextime.Set(time0)

	// Create some timers using AfterFunc with a custom callback
	flextime.AfterFunc(200*time.Millisecond, func() {
		log.Printf("AfterFunc1 fired, time:%s", flextime.Now().Format(timeFormat))
	})
	flextime.AfterFunc(50*time.Millisecond, func() {
		log.Printf("AfterFunc2 fired, time:%s", flextime.Now().Format(timeFormat))
	})

	// Create some regular timers
	var mytimers []*flextime.Timer
	mytimers = append(mytimers, flextime.NewTimer(1*time.Second))
	mytimers = append(mytimers, flextime.NewTimer(2*time.Second))
	mytimers = append(mytimers, flextime.NewTimer(5*time.Second))
	mytimers = append(mytimers, flextime.NewTimer(100*time.Millisecond))

	// Create some tickers
	var mytickers []*flextime.Ticker
	mytickers = append(mytickers, flextime.NewTicker(500*time.Millisecond))

	go func() {
		flextime.Sleep(2 * time.Second)
		log.Printf("Came after Sleep 2 seconds, time:%s", flextime.Now().Format(timeFormat))
	}()

	flextime.Sleep(3 * time.Second)

	// This is needed to let other goroutines run
	time.Sleep(time.Millisecond)

	for i, tmr := range mytimers {
		var val string
		select {
		case t := <-tmr.C:
			val = fmt.Sprintf("fired, time:%s", t.Format(timeFormat))
		default:
			val = "not fired yet"
		}

		log.Printf("Timer #%d: %s", i, val)
	}

	for i, tkr := range mytickers {
		var val string
		select {
		case t := <-tkr.C:
			val = fmt.Sprintf("fired, time:%s", t.Format(timeFormat))
		default:
			val = "not fired yet"
		}

		log.Printf("Ticker #%d: %s", i, val)
	}

	got := l.String()
	want := strings.Join([]string{
		"AfterFunc2 fired, time:2020-05-01T00:00:00.05Z",
		"AfterFunc1 fired, time:2020-05-01T00:00:00.2Z",
		"Came after Sleep 2 seconds, time:2020-05-01T00:00:02Z",
		"Timer #0: fired, time:2020-05-01T00:00:01Z",
		"Timer #1: fired, time:2020-05-01T00:00:02Z",
		"Timer #2: not fired yet",
		"Timer #3: fired, time:2020-05-01T00:00:00.1Z",
		"Ticker #0: fired, time:2020-05-01T00:00:00.5Z",
	}, "\n") + "\n"
	if got != want {
		t.Errorf("logs unmatched,\ngot:\n%s\nwant:\n%s\ndiff:\n%s", got, want, diff.Diff(got, want))
	}
}

テストの結果

time.Sleep(time.Millisecond) を消したときのテスト結果

$ go test -v
=== RUN   TestMockTime
    main_test.go:104: logs unmatched,
        got:
        Timer #0: not fired yet
        Timer #1: not fired yet
        Timer #2: not fired yet
        Timer #3: not fired yet
        Ticker #0: not fired yet

        want:
        AfterFunc2 fired, time:2020-05-01T00:00:00.05Z
        AfterFunc1 fired, time:2020-05-01T00:00:00.2Z
        Came after Sleep 2 seconds, time:2020-05-01T00:00:02Z
        Timer #0: fired, time:2020-05-01T00:00:01Z
        Timer #1: fired, time:2020-05-01T00:00:02Z
        Timer #2: not fired yet
        Timer #3: fired, time:2020-05-01T00:00:00.1Z
        Ticker #0: fired, time:2020-05-01T00:00:00.5Z

        diff:
        -Timer #0: not fired yet
        -Timer #1: not fired yet
        +AfterFunc2 fired, time:2020-05-01T00:00:00.05Z
        +AfterFunc1 fired, time:2020-05-01T00:00:00.2Z
        +Came after Sleep 2 seconds, time:2020-05-01T00:00:02Z
        +Timer #0: fired, time:2020-05-01T00:00:01Z
        +Timer #1: fired, time:2020-05-01T00:00:02Z
         Timer #2: not fired yet
        -Timer #3: not fired yet
        -Ticker #0: not fired yet
        +Timer #3: fired, time:2020-05-01T00:00:00.1Z
        +Ticker #0: fired, time:2020-05-01T00:00:00.5Z

--- FAIL: TestMockTime (0.00s)
FAIL
exit status 1
FAIL    github.com/hnakamur/go-mock-clock-experiment    0.002s

time.Sleep(time.Millisecond) がある状態でのテスト結果

$ go test -v
=== RUN   TestMockTime
    main_test.go:107: logs unmatched,
        got:
        Came after Sleep 2 seconds, time:2020-05-01T00:00:05Z
        AfterFunc1 fired, time:2020-05-01T00:00:05Z
        AfterFunc2 fired, time:2020-05-01T00:00:05Z
        Timer #0: fired, time:2020-05-01T00:00:01Z
        Timer #1: fired, time:2020-05-01T00:00:02Z
        Timer #2: fired, time:2020-05-01T00:00:05Z
        Timer #3: fired, time:2020-05-01T00:00:00.1Z
        Ticker #0: fired, time:2020-05-01T00:00:00.5Z

        want:
        AfterFunc2 fired, time:2020-05-01T00:00:00.05Z
        AfterFunc1 fired, time:2020-05-01T00:00:00.2Z
        Came after Sleep 2 seconds, time:2020-05-01T00:00:02Z
        Timer #0: fired, time:2020-05-01T00:00:01Z
        Timer #1: fired, time:2020-05-01T00:00:02Z
        Timer #2: not fired yet
        Timer #3: fired, time:2020-05-01T00:00:00.1Z
        Ticker #0: fired, time:2020-05-01T00:00:00.5Z

        diff:
        -Came after Sleep 2 seconds, time:2020-05-01T00:00:05Z
        -AfterFunc1 fired, time:2020-05-01T00:00:05Z
        -AfterFunc2 fired, time:2020-05-01T00:00:05Z
        +AfterFunc2 fired, time:2020-05-01T00:00:00.05Z
        +AfterFunc1 fired, time:2020-05-01T00:00:00.2Z
        +Came after Sleep 2 seconds, time:2020-05-01T00:00:02Z
         Timer #0: fired, time:2020-05-01T00:00:01Z
         Timer #1: fired, time:2020-05-01T00:00:02Z
        -Timer #2: fired, time:2020-05-01T00:00:05Z
        +Timer #2: not fired yet
         Timer #3: fired, time:2020-05-01T00:00:00.1Z
         Ticker #0: fired, time:2020-05-01T00:00:00.5Z

--- FAIL: TestMockTime (0.00s)
FAIL
exit status 1
FAIL    github.com/hnakamur/go-mock-clock-experiment    0.003s

Came after SleepAfterFunc1AfterFunc2 の順番と時刻が違うのと Timer #2 が発動してしまっています。

これは flextime.Sleep がモックの時計の時刻を進めるという実装なので、複数の gorotine で呼ぶと合計の時間がすぐに進むからと推測されます。

実は私も time.Now() だけ差し替える実装を書いたときに Sleep でモックの時計を進めるように書いていました。 複数の goroutine で Sleep を呼ぶケースを考えるとモックの時計がその時間過ぎるまではブロックする必要があるので、他のライブラリーのように Sleep とは別にモックの時計を進める API を用意する必要がありそうです。

code.cloudfoundry.org/clock

time パッケージからの移行

clock package · pkg.go.dev の Clock インタフェースを使うように書き換える必要があります。

production では NewClock、テスト時は fakeclock.NewFakeClock 関数でインスタンスを作ります。

モックの時計を進めるのは IncrementIncrementBySeconds メソッドを使います。

Clock インタフェースに AfterFunc メソッドは無いので NewTimer を使って以下のテストコードのように自前で実装する必要があります。

テストコード

code.cloudfoundry.org/clock v1.0.0 を使ったサンプルを hnakamur/go-mock-clock-experiment の using_cloudfoundry_clock ブランチ に置いています。

package main

import (
	"fmt"
	"log"
	"strings"
	"sync"
	"testing"
	"time"

	"code.cloudfoundry.org/clock"
	"code.cloudfoundry.org/clock/fakeclock"
	"github.com/kylelemons/godebug/diff"
)

const timeFormat = "2006-01-02T15:04:05.999Z07:00"

type strLog struct {
	b  strings.Builder
	mu sync.Mutex
}

func (l *strLog) Write(p []byte) (int, error) {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.b.Write(p)
}

func (l *strLog) String() string {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.b.String()
}

func afterFunc(c clock.Clock, d time.Duration, f func()) clock.Timer {
	timer := c.NewTimer(d)
	go func() {
		<-timer.C()
		f()
	}()
	return timer
}

func TestMockTime(t *testing.T) {
	l := &strLog{}
	log.SetOutput(l)
	log.SetFlags(0)

	c := fakeclock.NewFakeClock(time.Date(2020, 5, 1, 0, 0, 0, 0, time.UTC))

	// Create some timers using AfterFunc with a custom callback
	afterFunc(c, 200*time.Millisecond, func() {
		log.Printf("AfterFunc1 fired, time:%s", c.Now().Format(timeFormat))
	})
	afterFunc(c, 50*time.Millisecond, func() {
		log.Printf("AfterFunc2 fired, time:%s", c.Now().Format(timeFormat))
	})

	// Create some regular timers
	var mytimers []clock.Timer
	mytimers = append(mytimers, c.NewTimer(1*time.Second))
	mytimers = append(mytimers, c.NewTimer(2*time.Second))
	mytimers = append(mytimers, c.NewTimer(5*time.Second))
	mytimers = append(mytimers, c.NewTimer(100*time.Millisecond))

	// Create some tickers
	var mytickers []clock.Ticker
	mytickers = append(mytickers, c.NewTicker(500*time.Millisecond))

	// Create goroutine calling sleep
	go func() {
		c.Sleep(2 * time.Second)
		log.Printf("Came after Sleep 2 seconds, time:%s", c.Now().Format(timeFormat))
	}()

	c.Increment(3 * time.Second)

	// This is needed to let other goroutines run.
	// See https://dmitryfrank.com/articles/mocking_time_in_go
	time.Sleep(time.Millisecond)

	for i, tmr := range mytimers {
		var val string
		select {
		case t := <-tmr.C():
			val = fmt.Sprintf("fired, time:%s", t.Format(timeFormat))
		default:
			val = "not fired yet"
		}

		log.Printf("Timer #%d: %s", i, val)
	}

	for i, tkr := range mytickers {
		var val string
		select {
		case t := <-tkr.C():
			val = fmt.Sprintf("fired, time:%s", t.Format(timeFormat))
		default:
			val = "not fired yet"
		}

		log.Printf("Ticker #%d: %s", i, val)
	}

	got := l.String()
	want := strings.Join([]string{
		"AfterFunc2 fired, time:2020-05-01T00:00:00.05Z",
		"AfterFunc1 fired, time:2020-05-01T00:00:00.2Z",
		"Came after Sleep 2 seconds, time:2020-05-01T00:00:05Z",
		"Timer #0: fired, time:2020-05-01T00:00:01Z",
		"Timer #1: fired, time:2020-05-01T00:00:02Z",
		"Timer #2: not fired yet",
		"Timer #3: fired, time:2020-05-01T00:00:00.1Z",
		"Ticker #0: fired, time:2020-05-01T00:00:00.5Z",
	}, "\n") + "\n"
	if got != want {
		t.Errorf("logs unmatched,\ngot:\n%s\nwant:\n%s\ndiff:\n%s", got, want, diff.Diff(got, want))
	}
}

テストの結果

$ go test -v
=== RUN   TestMockTime
    main_test.go:118: logs unmatched,
        got:
        AfterFunc1 fired, time:2020-05-01T00:00:03Z
        AfterFunc2 fired, time:2020-05-01T00:00:03Z
        Timer #0: fired, time:2020-05-01T00:00:03Z
        Timer #1: fired, time:2020-05-01T00:00:03Z
        Timer #2: not fired yet
        Timer #3: fired, time:2020-05-01T00:00:03Z
        Ticker #0: fired, time:2020-05-01T00:00:03Z

        want:
        AfterFunc2 fired, time:2020-05-01T00:00:00.05Z
        AfterFunc1 fired, time:2020-05-01T00:00:00.2Z
        Came after Sleep 2 seconds, time:2020-05-01T00:00:05Z
        Timer #0: fired, time:2020-05-01T00:00:01Z
        Timer #1: fired, time:2020-05-01T00:00:02Z
        Timer #2: not fired yet
        Timer #3: fired, time:2020-05-01T00:00:00.1Z
        Ticker #0: fired, time:2020-05-01T00:00:00.5Z

        diff:
        -AfterFunc1 fired, time:2020-05-01T00:00:03Z
        -AfterFunc2 fired, time:2020-05-01T00:00:03Z
        -Timer #0: fired, time:2020-05-01T00:00:03Z
        -Timer #1: fired, time:2020-05-01T00:00:03Z
        +AfterFunc2 fired, time:2020-05-01T00:00:00.05Z
        +AfterFunc1 fired, time:2020-05-01T00:00:00.2Z
        +Came after Sleep 2 seconds, time:2020-05-01T00:00:05Z
        +Timer #0: fired, time:2020-05-01T00:00:01Z
        +Timer #1: fired, time:2020-05-01T00:00:02Z
         Timer #2: not fired yet
        -Timer #3: fired, time:2020-05-01T00:00:03Z
        -Ticker #0: fired, time:2020-05-01T00:00:03Z
        +Timer #3: fired, time:2020-05-01T00:00:00.1Z
        +Ticker #0: fired, time:2020-05-01T00:00:00.5Z

--- FAIL: TestMockTime (0.00s)
FAIL
exit status 1
FAIL    github.com/hnakamur/go-mock-clock-experiment    0.003s

Sleep の後のコードが実行されておらず、Timer, Ticker, AfterFunc の時刻がモックの時計を進めた後の時刻になっています。

github.com/thejerf/abtime

What approach do you use to mock time.Now() ? : golangコメント で知りました。

time パッケージからの移行

abtime package · pkg.go.devAbstractTime というインターフェースがありこれを使うように書き換える必要があります。

production では NewRealTime、テストでは NewManualNewManualAtTime でインスタンスを生成します。

モックの時計の時刻を進めるのは Advance メソッドです。

AbstractTime インターフェースで TimerTicker などを作る際にユニークな ID を指定する必要があり、これが厳しいです。

さらにそれを発動させるには Trigger メソッドを呼ぶ必要があります。

一応テストはしてみましたが結果も芳しくないので省略します。

github.com/facebookarchive/clock

facebookarchive/clock: Clock is a small library for mocking time in Go. はアーカイブされていたのでスルーしました。

benbjohnson/clock: Clock is a small library for mocking time in Go. の fork でした。

github.com/jmhodges/clock

clock package · pkg.go.devClock インターフェースに NewTimer メソッドはありますが NewTicker メソッドは無いのを見てスルーしました。

github.com/benbjohnson/clock

v1.0.3 を hnakamur/go-mock-clock-experiment の using_github_com_benbjohnson_clock ブランチ で試しました。

time パッケージからの移行

clock package · pkg.go.devClock インターフェースに合わせて書き換える必要があります。

production では New、テストでは NewMock でインスタンスを作成します。

モックの時計は Add メソッドで進めます。

Mocking time and testing event loops in Go [Dmitry Frank] でも指摘されていますが、 Go 標準ライブラリーの time パッケージでは NewTimerNewTicker という関数名ですが、 Clock インターフェースでは対応するメソッド名が TimerTicker と違うので要注意です。

テストコード

package main

import (
	"fmt"
	"log"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/benbjohnson/clock"
	"github.com/kylelemons/godebug/diff"
)

const timeFormat = "2006-01-02T15:04:05.999Z07:00"

type strLog struct {
	b  strings.Builder
	mu sync.Mutex
}

func (l *strLog) Write(p []byte) (int, error) {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.b.Write(p)
}

func (l *strLog) String() string {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.b.String()
}

func TestMockTime(t *testing.T) {
	l := &strLog{}
	log.SetOutput(l)
	log.SetFlags(0)

	c := clock.NewMock()
	c.Set(time.Date(2020, 5, 1, 0, 0, 0, 0, time.UTC))

	// Create some timers using AfterFunc with a custom callback
	c.AfterFunc(200*time.Millisecond, func() {
		log.Printf("AfterFunc1 fired, time:%s", c.Now().Format(timeFormat))
	})
	c.AfterFunc(50*time.Millisecond, func() {
		log.Printf("AfterFunc2 fired, time:%s", c.Now().Format(timeFormat))
	})

	// Create some regular timers
	var mytimers []*clock.Timer
	mytimers = append(mytimers, c.Timer(1*time.Second))
	mytimers = append(mytimers, c.Timer(2*time.Second))
	mytimers = append(mytimers, c.Timer(5*time.Second))
	mytimers = append(mytimers, c.Timer(100*time.Millisecond))

	// Create some tickers
	var mytickers []*clock.Ticker
	mytickers = append(mytickers, c.Ticker(500*time.Millisecond))

	go func() {
		c.Sleep(2 * time.Second)
		log.Printf("Came after Sleep 2 seconds, time:%s", c.Now().Format(timeFormat))
	}()

	c.Add(3 * time.Second)

	for i, tmr := range mytimers {
		var val string
		select {
		case t := <-tmr.C:
			val = fmt.Sprintf("fired, time:%s", t.Format(timeFormat))
		default:
			val = "not fired yet"
		}

		log.Printf("Timer #%d: %s", i, val)
	}

	for i, tkr := range mytickers {
		var val string
		select {
		case t := <-tkr.C:
			val = fmt.Sprintf("fired, time:%s", t.Format(timeFormat))
		default:
			val = "not fired yet"
		}

		log.Printf("Ticker #%d: %s", i, val)
	}

	got := l.String()
	want := strings.Join([]string{
		"AfterFunc2 fired, time:2020-05-01T00:00:00.05Z",
		"AfterFunc1 fired, time:2020-05-01T00:00:00.2Z",
		"Came after Sleep 2 seconds, time:2020-05-01T00:00:02Z",
		"Timer #0: fired, time:2020-05-01T00:00:01Z",
		"Timer #1: fired, time:2020-05-01T00:00:02Z",
		"Timer #2: not fired yet",
		"Timer #3: fired, time:2020-05-01T00:00:00.1Z",
		"Ticker #0: fired, time:2020-05-01T00:00:00.5Z",
	}, "\n") + "\n"
	if got != want {
		t.Errorf("logs unmatched,\ngot:\n%s\n\nwant:\n%s\n\ndiff:\n%s", got, want, diff.Diff(got, want))
	}
}

テストの結果

テストはブロックしてしまい、 Ctrl-C を押して止める必要がありました。

$ go test -v
=== RUN   TestMockTime
^Csignal: interrupt
FAIL    github.com/hnakamur/go-mock-clock-experiment    4.420s

github.com/dimonomid/clock

Mocking time and testing event loops in Go [Dmitry Frank] の著者による github.com/benbjohnson/clock の fork です。

time パッケージからの移行

github.com/benbjohnson/clock と同様です。

テストコード

github.com/benbjohnson/clock と同じですが、 github.com/dimonomid/clock は fork ですがパッケージ名は変えていないので go.mod に以下のように replace を書く必要がありました(replace についての詳細は When should I use the replace directive? を参照)。

replace github.com/benbjohnson/clock => github.com/dimonomid/clock v0.0.0-20200123114523-8d1048cafc4d

テストの結果

$ go test -v
=== RUN   TestMockTime
    main_test.go:104: logs unmatched,
        got:
        AfterFunc2 fired, time:2020-05-01T00:00:00.05Z
        AfterFunc1 fired, time:2020-05-01T00:00:00.2Z
        Came after Sleep 2 seconds, time:2020-05-01T00:00:02.5Z
        Timer #0: fired, time:2020-05-01T00:00:01Z
        Timer #1: fired, time:2020-05-01T00:00:02Z
        Timer #2: not fired yet
        Timer #3: fired, time:2020-05-01T00:00:00.1Z
        Ticker #0: fired, time:2020-05-01T00:00:00.5Z

        want:
        AfterFunc2 fired, time:2020-05-01T00:00:00.05Z
        AfterFunc1 fired, time:2020-05-01T00:00:00.2Z
        Came after Sleep 2 seconds, time:2020-05-01T00:00:02Z
        Timer #0: fired, time:2020-05-01T00:00:01Z
        Timer #1: fired, time:2020-05-01T00:00:02Z
        Timer #2: not fired yet
        Timer #3: fired, time:2020-05-01T00:00:00.1Z
        Ticker #0: fired, time:2020-05-01T00:00:00.5Z

        diff:
         AfterFunc2 fired, time:2020-05-01T00:00:00.05Z
         AfterFunc1 fired, time:2020-05-01T00:00:00.2Z
        -Came after Sleep 2 seconds, time:2020-05-01T00:00:02.5Z
        +Came after Sleep 2 seconds, time:2020-05-01T00:00:02Z
         Timer #0: fired, time:2020-05-01T00:00:01Z
         Timer #1: fired, time:2020-05-01T00:00:02Z
         Timer #2: not fired yet
         Timer #3: fired, time:2020-05-01T00:00:00.1Z
         Ticker #0: fired, time:2020-05-01T00:00:00.5Z

--- FAIL: TestMockTime (0.02s)
FAIL
exit status 1
FAIL    github.com/hnakamur/go-mock-clock-experiment    0.017s

不一致なのは Sleep の後の時刻だけで他は全て期待通りです。素晴らしい!

Sleep した後の時刻は Go のランタイム次第で Sleep の次の行の時刻が変わりうるので、そもそも特定の時刻を期待してはいけないです。

と言いつつ、ミリ秒の単位なら良いのではないかという甘い期待で上記のテストコードにしています。 ですが、上記の結果では 500ms のずれが起きています。

clock/clock.go at master · dimonomid/clock を見てみたのですが、どこにも 500ms という値は無く、この現象が起きる理由はわかりませんでした。

おわりに

今回試した中では dimonomid/clock: Clock is a small library for mocking time in Go. が一番良かったです。

ただ、 Mocking time and testing event loops in Go [Dmitry Frank] で詳しく解説されているように、チャンネルを扱っている部分ではさらに対応が必要な場合があります。

O’Reilly Japan - Go言語による並行処理 でも紹介されているように同期用のフック関数を用意しておき production は何もしない no-op 的な関数をセットし、テストでは同期用のチャンネルと送受信して、タイミングを制御するという技などを使ったりします。

ですが、それで制御できるのはあくまで特定の時点までブロックさせることだけです。 チャンネルを送信・受信したあとのコードがどのタイミングで実行されるかは Go のランタイム次第だということを忘れないよう注意が必要です。