Golangで関数をグローバル変数に代入してテスト時にスタブする

こんにちは、小野マトペです。タイトル全部です。

昨日、現在時刻によって条件分岐するロジックを含むGoプログラムを書いていて、どうテストするか困ったのですが、うまい(と思う)やり方を思いついたのでここに書いておきます。

書いていたのはまあだいたいこういうソースコードです。

// main.go
package main

import (
        "fmt"
        "time"
)

func main() {
        fmt.Println(Greet("マトペ"))
}

func Greet(n string) string {
        t := time.Now()
        if 6 <= t.Hour() && t.Hour() <= 18 {
                return fmt.Sprintf("こんにちは%sさん。今は%d時ですよ!", n, t.Hour())
        } else {
                return fmt.Sprintf("こんばんは%sさん。今は%d時ですよ!", n, t.Hour())
        }
}
go run main.go
こんにちはマトペさん。今は12時ですよ!

で、このGreet関数をテストしたいんですが、このままでは夜の挨拶を出すのに18時まで待つ必要があります。それでは困るのでtime.Nowの部分をなんとかちょろまかしていつでも全ての条件をテスト出来るようしないといけません。いわゆるスタブ化ですね。

すぐに思い浮かぶのは、Greet関数を下のようにDIっぽく依存性である現在時刻を引数から受け取るように変更する方法だと思います。

// main.go
func Greet(n string, t time.Time) string { // 引数に現在時刻を追加
        if 6 <= t.Hour() && t.Hour() <= 18 {
                return fmt.Sprintf("こんにちは%sさん。今は%d時ですよ!", n, t.Hour())
        } else {
                return fmt.Sprintf("こんばんは%sさん。今は%d時ですよ!", n, t.Hour())
        }
}

これなら任意の時間の場合のGreet関数のユニットテストが書けますが、現在時刻のテストのために本来不要な引数が増えるのは大げさで好ましくありません(好みの問題です)。それに、結局コールスタックのどこかでtime.Now()を呼ぶ必要があり、その呼び出しポイントを含むテストはスタブ化できません。

ここはやはりtime.Now関数をテスト実行時だけ動的に上書きしたいところですが、Golangはあまりスタブ化に優しい言語ではなく、例えばtimeパッケージをテスト時に動的に他のパッケージに差し替えるとかはできません。これが自作パッケージで提供している構造体かなにかであれば、interfaceでもなんでも使ってスタバブルにすればいいと思いますが…。

(参考:go - Is there an easy way to stub out time.Now() globally in golang during test? - Stack Overflow

で、どうしたかというと、タイトル通りですがmain.goでtime.Nowの関数ポインタをグローバル変数に代入しておき、テスト時にmain_test.goで任意の時刻を返す関数にさしかえるという方法を使いました。

// main.go
package main

import (
        "fmt"
        "time"
)

var timeNowFunc = time.Now // 関数をグローバル変数に代入

func main() {
        fmt.Println(Greet("マトペ"))
}

func Greet(n string) string {
        t := timeNowFunc() // グローバル変数でtime.Now関数を呼び出し
        if 6 <= t.Hour() && t.Hour() <= 18 {
                return fmt.Sprintf("こんにちは%sさん。今は%d時ですよ!", n, t.Hour())
        } else {
                return fmt.Sprintf("こんばんは%sさん。今は%d時ですよ!", n, t.Hour())
        }
}

Goは関数が一級市民なのでシグネチャさえ合致すれば変数に代入できます。この場合、timeNowFunc変数は型推論func() time.Time型として宣言され、time.Now関数で初期化されます。で、これを次のようにテストします。

// main_test.go
package main

import (
        "testing"
        "time"
)

const timeformat = "2006-01-02 15:04:06" // timeのフォーマット指定文字列

// timeNowFuncグローバル変数の関数を入れ替える関数
func setNow(t time.Time) {
        timeNowFunc = func() time.Time { return t }
}

func TestMain(t *testing.T) {

        evening, _ := time.Parse(timeformat, "2014-08-14 14:10:00")
        night, _ := time.Parse(timeformat, "2014-08-14 22:30:00")

        // 昼のテスト
        setNow(evening)  // スタブ!timeNowFuncに昼時刻を返す関数をセット
        if ret := Greet("まとぺ"); ret != "こんにちはまとぺさん。今は14時ですよ!" {
                t.Errorf("Greet Fails. Got [%s]\n", ret)
        }

        // 夜のテスト
        setNow(night)  // スタブ!timeNowFuncに昼時刻を返す関数をセット
        if ret := Greet("まとぺ"); ret != "こんばんはまとぺさん。今は22時ですよ!" {
                t.Errorf("Greet Fails. Got [%s]\n", ret)
        }
}
$ go test
PASS
ok      _/Users/matope/dev/go/mock     0.006s

テスト内で宣言しているsetNow(time.Time)関数は、グローバル変数のtimeNowFuncに、任意の時刻を返す関数を代入する関数です。これでテストごとにtimeNowFuncの返す時刻をコントロールできるので、任意の時刻のテストが書けます。しかも面倒なスタブ用クラス実装や不要な引数の引き回しなしで、テスト時にのみ作用するスタブです。

グローバル変数と聞いてつらくなる人もいるかもしれませんが、Goのグローバル変数は実際にはパッケージスコープで、頭文字を小文字にしておけばパッケージの外からアクセスできないので、比較的クリーンです。Goはテストがコードと同じパッケージ空間で実行できるので、テストコードに対してのみExportされたパラメータとして扱えます。

この、テスト時にスタブしたい関数の呼び出しをグローバル変数でプロキシしておいてtest側の初期化で差し替える方法は、同一パッケージ内であればモック化のための追加の記述量が少なく、データベースのテストなど、いろいろ応用が利きそうだなと思いました。複数パッケージにまたがるtime.Now()を一括で差し替えたいような場合は、スタブプロキシ用のパッケージを作ってそこのExported(大文字から始まる)グローバル変数にセットしてやればよさそうですね。

ちなみに豆ですが、Grouped Globalを使えばこんな感じでちょっとかっこよくグローバル変数を呼べます。もはや紛らわしいような気もしますが。

// main.go
package main

import (
        "fmt"
        "time"
)

var _time = struct {
        Now   func() time.Time
        Since func(time.Time) time.Duration
}{
        time.Now,
        time.Since,
}

func main() {
        fmt.Println(_time.Now()) // ← ちょっとかっこいい
}

追記:同じやり方を解説してる記事 があったのでこっち読んだ方がよさそう