スレッドセーフなテスト用の時間を固定するライブラリを作った
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もお待ちしています。