time.Now() が厄介なのはフリー関数だからである
テストできない 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で差し替えが効くのは、
になるので、フリー関数をそれらの形に合わせれば、制御は可能になります。
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 を、実際のコードでは RealClock を IsTodayNewYearsDay に注入します。
附録: 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を実装したテストダブル を、実際のコードでは WrappedEnv を EnrichEmailTitle に注入します。
附録: IsTodayNewYearsDay も EnrichEmailTitle もフリー関数なのでは?
はい、コードの説明を簡潔にするためにフリー関数にしました。実際の利用では何かのtypeに付けてメソッド化します。
Further Reading
ここで紹介したやり方は、 Working Effectively with Legacy Code という本で Encapsulate Global References として登場しています。
Discussion
いちおうテスト時に差し替える方法はあったりします。
それは
time.Now()に限った対症療法になりますねtime.Now()のみならず 、この構文になっているフリー関数全般を制御可能にする方法を提唱するのが主旨でしたそのモジュールが裏で利用しているoverlayオプションを駆使するとtimeパッケージに限らずに差し替えすることはできます!
そうですね
コードテキストにinterfaceを登場させるか、コードテキストに触らずにビルドツールのカスタマイズにコストをかけるかのトレードオフになりますね