newmo 技術ブログ

技術で地域をカラフルに

go testの時だけ時刻を固定する

はじめに

こんにちは。newmoでソフトウェアエンジニアをやっている@tenntennです。 newmoには2024年8月に入社しました。この記事を書いているのは2024年9月なので、入社してだいたい1ヶ月ちょっとが経過したところです。 なお、筆者が入社した経緯などは次の記事を読んでください。

note.com

入社した当初、newmoのバックエンドコードのコードを眺めていると、次のように宣言された関数を見つけました。

func Now(_ context.Context) time.Time {
    return time.Now().In(time.UTC)
}

単にtime.Now関数を呼び出して、LocationUTCに設定しているだけです。 しかも、引数はブランク識別子になっているので使用していません。

しかし、筆者はこれを見て、これは後々のことを考えているなと感心しました。 ちなみに変数と違って関数内で使用してなくてもコンパイルエラーにはならないためブランク識別子(_)にする必要はありません。 それにも関わらず、あえてブランク識別子にしているのは、今は使ってないけど将来は使いますよと気持ちを暗示しているように見えました。 (意図は社内で確認してないので、考えすぎかもしれません。)

ちなみに、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ディレクトリが存在するディレクトリ以下でしか参照できません。 詳細は筆者が前職時代に書いた記事を参照してください。

qiita.com

それでは、配置し直した各ファイルの中身を見ていきましょう。 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で公開されています。

github.com

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ページ超えでちょっとだけ長いですが次のスライドが参考になります。

docs.google.com

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" ./...

github.com

おわりに

本稿では、newmoで活用しているgo test時に時刻を固定する方法とOSS化しているctxtimeパッケージとtestidパッケージの紹介をしました。 time.Now関数だけではなく、time.Ticker型を使ったコードなどもテストしにくいので、今後のアップデートで対応できたらと考えています。

newmoではスピード感を保ちながら新しいプロダクトを開発しつつ、技術へのチャレンジを惜しまず日々の開発を行っています。 これからもプロダクト開発の中で生まれたライブラリや知見は、惜しまず技術コミュニティにお返しできればと考えています。

PR: newmoではエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

careers.newmo.me