🧮

time.Now() が厄介なのはフリー関数だからである

2023/04/24に公開4

フリー関数: 何かのtypeに付いていない関数
GoではFunctionMethodとに呼び分けられている。

テストできない time.Now() のクライアント

time.Now() を直接呼び出す関数はテストできないということが最近、社内で話題に上がっています。

例えばこんなもの。

// 今日が元日かどうかをチェックする。
func IsTodayNewYearsDay() bool {
	now := time.Now()
	return now.Month() == time.January && now.Day() == 1
}
  • 今日が元日ならtrueが戻る
  • 今日が元日でなかったらfalseが戻る

というテストが実行できない形になっていますね。

テストできないのはなぜか

time.Now() は呼び出しごとに戻り値が変わるから制御が効かない、という time.Now() の特性が注目されがちですが、ここでは package_name.FreeFunctionName() という構文自体に問題がある、という見方を提唱したいと思います。

下記の例を見てみましょう。

import env "..."

// 開発環境で送信するメールのタイトルにプレフィックスを付ける。
func EnrichEmailTitle(title string) string {
	if env.IsDevelopment() {
		return "[開発環境] " + title
	}
	if env.IsProduction() {
		return title
	}
	panic("unknown environment")
}

// -------- 別ファイル --------

package env

import "os"

var name string

func init() { name = os.Getenv("ENV") }

// 開発環境であるかどうか。
func IsDevelopment() bool { return name == "dev" }

// 本番環境であるかどうか。
func IsProduction() bool { return name == "prod" }

EnrichEmailTitle の挙動を検証するためには、

  • 開発環境でプレフィックスが付く
  • 本番環境でプレフィックスが付かない
  • 未知の環境では動作しない

というテストケースが必要ですが、 env を最初にimportしたタイミングで env.name が決まるので、テストで自由に切り替えることはできないようになっていますね。

フリー関数は差し替えられない

time.Now() が制御できないのも、env.IsDevelopment() env.IsProduction() が制御できないのも、 差し替えが効かないフリー関数を直接呼び出しているからである、 と言うことができます。

Goで差し替えが効くのは、

  • 引数として受け取ったfunc
  • 引数として受け取ったinterfaceのメソッド

になるので、フリー関数をそれらの形に合わせれば、制御は可能になります。

time.Now() 問題を Clock で解決する

現在時刻が分かるinterface、 Clock を導入します。

package clock

// 現在時刻が分かる時計
type Clock interface {
	Now() time.Time
}

// 現在時刻を随時取得できる本物の時計
type RealClock struct{}

func (c RealClock) Now() time.Time {
	return time.Now()
}

// 特定の瞬間を現在時刻として扱う、止まっている時計
type StoppedClock struct {
	Moment time.Time
}

func (c StoppedClock) Now() time.Time {
	return c.Moment
}

// -------- 別ファイル --------

import clock "..."

// 今日が元日かどうかをチェックする。
func IsTodayNewYearsDay(c clock.Clock) bool {
	now := c.Now()
	return now.Month() == time.January && now.Day() == 1
}

テストでは StoppedClock を、実際のコードでは RealClockIsTodayNewYearsDay に注入します。

附録: EnrichEmailTitle をテスト可能にする

import env "..."

type Env interface {
	IsDevelopment() bool
	IsProduction() bool
}

type WrappedEnv struct{}

func (WrappedEnv) IsDevelopment() bool {
	return env.IsDevelopment()
}

func (WrappedEnv) IsProduction() bool {
	return env.IsDevelopment()
}

// 開発環境で送信するメールのタイトルにプレフィックスを付ける。
func EnrichEmailTitle(e Env, title string) string {
	if e.IsDevelopment() {
		return "[開発環境] " + title
	}
	if e.IsProduction() {
		return title
	}
	panic("unknown environment")
}

テストでは Envを実装したテストダブル を、実際のコードでは WrappedEnvEnrichEmailTitle に注入します。

附録: IsTodayNewYearsDayEnrichEmailTitle もフリー関数なのでは?

はい、コードの説明を簡潔にするためにフリー関数にしました。実際の利用では何かのtypeに付けてメソッド化します。

Further Reading

ここで紹介したやり方は、 Working Effectively with Legacy Code という本で Encapsulate Global References として登場しています。

Voicyテックブログ

Discussion

NoboNoboNoboNobo

いちおうテスト時に差し替える方法はあったりします。

https://github.com/tenntenn/testtime

NoboNoboNoboNobo

そのモジュールが裏で利用しているoverlayオプションを駆使するとtimeパッケージに限らずに差し替えすることはできます!

たくみんたくみん

そうですね
コードテキストにinterfaceを登場させるか、コードテキストに触らずにビルドツールのカスタマイズにコストをかけるかのトレードオフになりますね