スレッドセーフなテスト用の時間を固定するライブラリを作った

July 6, 2021

はじめに

time.Now関数を用いたコードをテストする場合、テスト対象のコードに次のような変更を加える必要があります。

  • 引数に現在時刻を渡す
  • パッケージ変数やフィールドなどに現在時刻を返す関数やインタフェースを設定する
  • context.WithValue関数でコンテキストに現在時刻を設ける

どの方法を用いてもプロジェクトの初期から考慮する必要があります。途中で変更するとなると修正が箇所を漏れなく探し出す必要があります。また、パッケージ変数に現在時刻を返す関数を設定した場合、テストを並列に実行することを諦める必要が出てくるでしょう。

このような課題を解決するために、testtimeというライブラリを作成しました。

テストの並列実行と時刻の固定

次のようにパッケージ変数を使ってtime.Now関数のラッパーの挙動を変えてテストする手法があります。

var nowFunc func() time.Time

func now() time.Time {
	if nowFunc != nil {
		return nowFunc()
	}
	return time.Now()
}

手軽に差し替えられるため便利ですが、テストの並列実行を考えると良い方法ではありません。パッケージ変数を用いているため、並列で実行されるテストケースでそれぞれパッケージ変数を差し替えてしまうと競合がおきます。ロックを取る方法もありますが、他のテストが終わるまで待つ必要が出てくるため、並列実行をする旨味がなくなります。

そこでtesttimeパッケージを用いるとテストケースごとに現在時刻を固定できます。使い方を見ていきましょう。

まず、テスト対象のコードを用意します。次のような現在時刻によって違うあいさつ文を返すDo関数を提供するgreetingパッケージをテストしましょう。

package greeting

import (
	"time"

	"github.com/tenntenn/testtime"
)

func Do() string {
	t := testtime.Now()
	switch h := t.Hour(); {
	case h >= 4 && h <= 9:
		return "おはよう"
	case h >= 10 && h <= 16:
		return "こんにちは"
	default:
		return "こんばんは"
	}
}

現在時刻を取得するために、time.Now関数ではなく、testtime.Now関数を用いていることに注意してください。

テストコードは次のように書けます。testtime.SetTime関数でtesttime.Now関数が返す時刻を固定しています。t.Parallelメソッドを呼んでいるため、各テストケースは並列で実行されますが問題ありません。

package greeting_test

import (
	"greeting"
	"testing"
	"time"

	"github.com/tenntenn/testtime"
)

func TestDo(t *testing.T) {
	t.Parallel()
	cases := []struct {
		tm   string
		want string
	}{
		{"04:00:00", "おはよう"},
		{"09:00:00", "おはよう"},
		{"10:00:00", "こんにちは"},
		{"16:00:00", "こんにちは"},
		{"17:00:00", "こんばんは"},
		{"03:00:00", "こんばんは"},
	}

	for _, tt := range cases {
		tt := tt
		t.Run(tt.tm, func(t *testing.T) {
			t.Parallel()
			testtime.SetTime(t, parseTime(t, tt.tm))
			got := greeting.Do()
			if got != tt.want {
				t.Errorf("want %s but got %s", tt.want, got)
			}
		})
	}
}

func parseTime(t *testing.T, v string) time.Time {
	t.Helper()
	tm, err := time.Parse("2006/01/02 15:04:05", "2006/01/02 "+v)
	if err != nil {
		t.Fatal("unexpected error:", err)
	}
	return tm
}

次のように、testtime.SetTime関数はスレッドセーフになっており、呼び出した関数名とゴールーチンのIDをキーにして時刻を記録しているため、ゴールーチンを跨いで用いられても正しく動作します。testtime.Now関数は、呼び出した関数を辿っていき、testtime.SetTime関数を呼び出した関数と同じ関数が見つかった場合は、設定されている時刻を返します。見つからない場合はtime.Now関数を呼び出します。

func SetTime(t *testing.T, tm time.Time) bool {
	t.Helper()
	// funcName関数はSetTime関数を呼び出した関数の情報を取得する
	name, ok := funcName(1)
	if !ok {
		return false
	}
	timeMap.Store(name, func() time.Time {
		return tm
	})

	t.Cleanup(func() {
		timeMap.Delete(name)
	})

	return true
}

func Now() time.Time {
	pcs := make([]uintptr, 10)
	n := runtime.Callers(1, pcs)
	frames := runtime.CallersFrames(pcs[:n])
	for {
		frame, hasNext := frames.Next()
		v, ok := timeMap.Load(goroutineID() + ":" + frame.Function)
		if ok {
			return v.(func() time.Time)()
		}

		if !hasNext {
			break
		}
	}
	return time.Now()
}

time.Now関数の置き換え

testtime.Now関数は非常に便利ですが、すでにあるコードに書かれたtime.Now関数の呼び出しを書き換える必要があります。そこでtesttimeの初期実装では、次のようにgo:linknameコメントディレクティブを用いて差し替える方法を用いていました。

//go:linkname now time.Now
func now() time.Time {
	return testtime.Now()
}

しかし、この方法では別のモジュールに記載されたtime.Nowを差し替えることができません。そこでGo1.16から導入された-overlayフラグを用いてtime.Now関数を差し替える方法に変更しました。実はgo.modファイルのreplaceディレクティブを用いる方法も考えました(実際は実装までやってしまった)が、標準パッケージには使えないため断念しました。

-overlayフラグはビルドやテストをする際にファイルを差し替えることができる機能です。次のようなJSONファイルのパスを指定します。

{
  "Replace": {
    "/usr/local/go/src/time/time.go": "/Users/tenntenn/go/pkg/testtime/time_go1.16.go"
  }
}

これは/usr/local/go/src/time/time.goの代わりに/Users/tenntenn/go/pkg/testtime/time_go1.16.goが用いられることを表します。

testtimeコマンドを用いると、差し替えるGoファイルとJSONファイルを生成し、JSONファイルのパスを返します。

$ go install github.com/tenntenn/testtime/cmd/testtime@latest
$ testtime
/Users/tenntenn/go/pkg/testtime/overlay_go1.16.json

次のようにgo testを実行する際に-overlayフラグにパスを指定すると、time.Now関数がtesttime.Now関数に置き換えられます。

$ go test -overlay=`testtime`
PASS
ok  	greeting	0.156s

testtimeコマンドは1度JSONファイルを生成すると-uフラグを指定するまで再生性を行いません。デフォルトでは、$GOPATH/pkg/testtime以下に各ファイルを生成します。

おわりに

testtimeを用いると既存のコードを変更せずに並列実行可能な時刻を固定するテストを書けます。time.Now関数を用いたコードのテストの並列実行を諦めていた方に1つの手段として届けばよいなと思っています。

バグや改善要望がある場合はGitHubでissueを挙げてください。PRもお待ちしています。