はじめに
こんにちは。newmoでソフトウェアエンジニアをやっている@tenntennです。 newmoには2024年8月に入社しました。この記事を書いているのは2024年9月なので、入社してだいたい1ヶ月ちょっとが経過したところです。 なお、筆者が入社した経緯などは次の記事を読んでください。
入社した当初、newmoのバックエンドコードのコードを眺めていると、次のように宣言された関数を見つけました。
func Now(_ context.Context) time.Time { return time.Now().In(time.UTC) }
単にtime.Now
関数を呼び出して、Location
をUTC
に設定しているだけです。
しかも、引数はブランク識別子になっているので使用していません。
しかし、筆者はこれを見て、これは後々のことを考えているなと感心しました。
ちなみに変数と違って関数内で使用してなくてもコンパイルエラーにはならないためブランク識別子(_
)にする必要はありません。
それにも関わらず、あえてブランク識別子にしているのは、今は使ってないけど将来は使いますよと気持ちを暗示しているように見えました。
(意図は社内で確認してないので、考えすぎかもしれません。)
ちなみに、Goの言語仕様では次のようにブランク識別子すら使用せず、型名だけでも問題ありません。 ただし、この形を取る場合は、すべての引数の識別子を省略する必要があります。
func Now(context.Context) time.Time { return time.Now().In(time.UTC) }
さて、この引数のコンテキストは何のためにあるのでしょうか? 実はこのコンテキストはテストの際に時刻をコントロールするためにあります。 特に時刻を特定の時間で固定するために使用することを想定しています。
その後、プロダクト開発していく中で時刻を固定してテストがしたくなり、筆者がコンテキストを使って時刻を固定できるように変更しました。 また、本稿を書くにあたって、ライブラリとして切り出しOSSとして公開してあります。
そこで、本稿ではコンテキストを使って時刻を固定する方法とその方法で実装されたライブラリについて紹介します。
コンテキストを使って時刻を固定する
テストのために時刻を固定する方法はいくつか存在します。 たとえば、筆者が書いた記事には次の方法が挙げられていました。
- 引数に現在時刻を渡す
- パッケージ変数やフィールドなどに現在時刻を返す関数やインタフェースを設定する
context.WithValue
関数でコンテキストに現在時刻を設ける
スレッドセーフなテスト用の時間を固定するライブラリを作った - tenntenn.dev
この記事ではさらに筆者が開発したtesttimeというライブラリが紹介されていました。
testtimeは便利ですが、linknameや-overlay
フラグなど、若干"やんちゃ"な機能を使用していました(詳細はtestimeの記事をご覧ください)。
テストのときだけとは言えども、できるなら"通常の"やり方で解決したいところです。 やはり、安全に時刻を固定するには、プロジェクトの初期からテストで時刻を固定したくなるときが来ることを考慮しておく方が良いでしょう。
冒頭に紹介したNow
関数は、コンテキストを引数に取っているため、後から如何様にも実装を拡充できるようになっています。
たとえば、次のようなWithFixedNow
関数を作成し、コンテキストに特定の時刻を紐づけることもできます。
func Now(ctx context.Context) time.Time { if testing.Testing() { return nowForTest(ctx) } return defaultNow(ctx) } func defaultNow(_ context.Context) time.Time { return time.Now().In(time.UTC) } func nowForTest(ctx context.Context) time.Time { now, ok := nowFromContext(ctx) if ok { return now } return defaultNow(ctx) } type ctxkey struct{} func WithFixedNow(t *testing.T, ctx context.Context, tm time.Time) context.Context { t.Helper() return context.WithValue(ctx, ctxkey{}, tm) } func nowFromContext(ctx context.Context) (time.Time, bool) { tm, ok := ctx.Value(ctxkey{}).(time.Time) return tm, ok }
WithFixedNow
の第1引数に*testing.T
型を指定する必要があるのは、テスト以外で呼ばれることを防ぐためです。
また、Now
関数はtesting.Testing
関数がtrue
を返す場合のみ、つまりテストの時だけコンテキストから時刻を取得するようにしています。
テストの時だけ挙動を変える
前述の方法では、テストのときにだけ時刻を固定することができますが、Now
関数を宣言したパッケージがtesting
パッケージをインポートする必要があります。
そこで、次にテスト以外ではtesting
パッケージをインポートせずに、テストの時だけ挙動を変える方法を考えてみましょう。
次のように、Now
関数を宣言しているパッケージをctxtime
と名付け、テストで使用するWithFixedNow
関数などは、サブパッケージのctxtimetest
パッケージに移動させます。
また、ctxtime
パッケージがtesting
パッケージに依存しないように、internal
パッケージを用意します。
ctxtime ├── ctxtime.go ├── ctxtimetest │ └── ctxtimetest.go └── internal └── now.go
Goにおいて、internal
パッケージは特別なパッケージです。
internal
という名前のディレクトリ以下に配置したソースコードやパッケージは、そのinternal
ディレクトリが存在するディレクトリ以下でしか参照できません。
詳細は筆者が前職時代に書いた記事を参照してください。
それでは、配置し直した各ファイルの中身を見ていきましょう。
ctxtime
パッケージには、次のようにNow
関数だけを配置します。
// ctxtime/ctxtime.go package ctxtime import ( "context" "time" "github.com/newmo-oss/ctxtime/internal" ) func Now(ctx context.Context) time.Time { return internal.Now(ctx) }
大部分の処理はinternal
パッケージに移動させます。
そして、internal
パッケージでは次のように宣言しておきます。
// ctxtime/internal/now.go package internal import ( "context" "time" ) var Now = DefaultNow func DefaultNow(_ context.Context) time.Time { return time.Now().In(time.UTC) }
ctxtime.Now
関数の挙動を変更できるように、internal.Now
は変数として宣言します。
デフォルト値としてinternal.DefaultNow
関数が設定されています。
そして、次のようにctxtimetest
パッケージのinit関数でinternal.Now
変数の値を変更しています。
// ctxtime/ctxtimetest/ctxtimetest.go package ctxtimetest import ( "context" "sync" "testing" "time" "github.com/newmo-oss/ctxtime/internal" ) func init() { if testing.Testing() { internal.Now = nowForTest } } func nowForTest(ctx context.Context) time.Time { now, ok := nowFromContext(ctx) if ok { return now } return internal.DefaultNow(ctx) } type ctxkey struct{} func WithFixedNow(t *testing.T, ctx context.Context, tm time.Time) context.Context { t.Helper() return context.WithValue(ctx, ctxkey{}, tm) } func nowFromContext(ctx context.Context) (time.Time, bool) { tm, ok := ctx.Value(ctxkey{}).(time.Time) return tm, ok }
このようにすることでctxtime
パッケージは、testing
パッケージに直接依存することなくテストの時だけ時刻を固定できるようになります。
テストごとのID
newmoでは、テストごとにtest id
と呼ばれるID(通常はUUID)を採番しています。
この他にテストを一意に識別するには、*testing.T
型のName
メソッドを用いる方法もあります。
しかし、テスト(サブテストも含む)でIDを複数用いたくなることを想定して、テスト名とは別にIDを付与することにしています。
また、テスト時にtest idをHTTPのヘッダやgRPCのメタデータに付与することで、どのテストからリクエストが来たか分かるようにしています。
testid
パッケージは、以下のリポジトリでOSSで公開されています。
newmoで開発・使用しているctxtime
パッケージも前述したコンテキストとキーを使った方法ではなく、次のようにこのtest idを使用する方法を使用しています。
WithFixedNow
関数の代わりにSetFixedNow
関数が用意され、UnsetFixedNow
関数で設定した時刻を削除できるようにしています。
なお、UnsetFixedNow
関数を呼ばなくてもテスト終了時に(*testing.T).Cleanup
関数で自動で削除されるように作られています。
// ctxtime/ctxtimetest/ctxtimetest.go package ctxtimetest import ( "context" "sync" "testing" "time" "github.com/newmo-oss/ctxtime/internal" "github.com/newmo-oss/testid" ) var fixedNows sync.Map func init() { if testing.Testing() { internal.Now = nowForTest } } // SetFixedNow fixes the return value of ctxtime.Now. // The fixed current time is set each test id which get from [testid.FromContext]. // If any test id cannot obtain from the context, the test will be fail with t.Fatal. // The fixed current time will be remove by t.Cleanup. func SetFixedNow(t testing.TB, ctx context.Context, tm time.Time) { t.Helper() tid, ok := testid.FromContext(ctx) if !ok { t.Fatal("failed to get test ID from the context") } t.Cleanup(func() { fixedNows.Delete(tid) }) fixedNows.Store(tid, tm) } // UnsetFixedNow removes the fixed current time which was set by [SetFixedNow]. // If any test id cannot obtain from the context, the test will be fail with t.Fatal. func UnsetFixedNow(t testing.TB, ctx context.Context) { t.Helper() tid, ok := testid.FromContext(ctx) if !ok { t.Fatal("failed to get test ID from the context") } fixedNows.Delete(tid) } func loadFixedTime(ctx context.Context) (time.Time, bool) { tid, ok := testid.FromContext(ctx) if !ok { return time.Time{}, false } v, ok := fixedNows.Load(tid) if !ok { return time.Time{}, false } tm, ok := v.(time.Time) if !ok { return time.Time{}, false } return tm, true } func nowForTest(ctx context.Context) time.Time { tm, ok := loadFixedTime(ctx) if !ok { return internal.DefaultNow(ctx) } return tm }
Linterを使ったtime.Now関数の呼び出しの検出
ctxtime.Now
関数を効果的に使うためには、プロジェクト全体でtime.Now
関数ではなくて、ctxtime.Now
関数を使用するというルールを設ける必要があります。
ルールを守っているかレビューでチェックするようにしていると、時間が経つにつれ形骸化しがちです。
そこでtime.Now
を使っている箇所を検出するctxtimechek
というLinterも合わせて作成しました。
なお、次のようにgolang.org/x/tools/go/analysis
パッケージ(以下、go/analysis
パッケージ)を用いて作成しています。
// ctxtime/ctxtimecheck/ctxtimecheck.go package ctxtimecheck import ( "go/types" "github.com/gostaticanalysis/analysisutil" "github.com/gostaticanalysis/ssainspect" "golang.org/x/tools/go/analysis" ) const doc = "ctxtimecheck finds calling time.Now instead of ctxtime.Now" // Analyzer finds calling time.Now instead of ctxtime.Now. var Analyzer = &analysis.Analyzer{ Name: "ctxtimecheck", Doc: doc, Run: run, Requires: []*analysis.Analyzer{ ssainspect.Analyzer, }, } func run(pass *analysis.Pass) (any, error) { in := pass.ResultOf[ssainspect.Analyzer].(*ssainspect.Inspector) timenow, _ := analysisutil.ObjectOf(pass, "time", "Now").(*types.Func) if timenow == nil { // skip return nil, nil } for in.Next() { c := in.Cursor() if analysisutil.Called(c.Instr, nil, timenow) { pass.Reportf(c.Instr.Pos(), "do not use %s, use ctxtime.Now", timenow.FullName()) } } return nil, nil }
Linterなどの静的解析ツールやgo/analysis
パッケージについては、300ページ超えでちょっとだけ長いですが次のスライドが参考になります。
time.Now
関数はもちろん、time.Now
関数をローカル変数に代入して呼び出している箇所も検出できます。
現在の実装ではパッケージ変数に代入した場合は検出できませんが、今後のバージョンアップで対応予定です。
ctxtimechek
は、次のようにgo install
を用いてインストールができます。
$ go install github.com/newmo-oss/ctxtime/ctxtimecheck/cmd/ctxtimecheck@latest
インストールした実行可能ファイルは、go vet
コマンドの-vettool
フラグに絶対パスを指定することで利用できます。
$ go vet -vettool=$(which ctxtimecheck) ./...
なお、現在のctxtimecheck
は筆者が開発しているcalled
という静的解析ツールでも同様の動作をします。
called
を導入している方は、次のように指定することで同様の結果が得られるでしょう。
$ go vet -vettool=$(which called) -called.funcs="time.Now" ./...
おわりに
本稿では、newmoで活用しているgo test時に時刻を固定する方法とOSS化しているctxtime
パッケージとtestid
パッケージの紹介をしました。
time.Now
関数だけではなく、time.Ticker
型を使ったコードなどもテストしにくいので、今後のアップデートで対応できたらと考えています。
newmoではスピード感を保ちながら新しいプロダクトを開発しつつ、技術へのチャレンジを惜しまず日々の開発を行っています。 これからもプロダクト開発の中で生まれたライブラリや知見は、惜しまず技術コミュニティにお返しできればと考えています。
PR: newmoではエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。