はじめに
はじめまして。Go初学者です。
Gopher道場#8や自身の勉強にて、気づいた学びを共有しています。
今回は、あるCLIツールのユニットテストを実装する際のflag引数の渡し方と、 flag.Parse() の実行位置によってテストが落ちる問題、またその理由についてまとめました。
結論から
- ユニットテスト時は
flag.CommandLine.Set(name, value string) errorを利用すると実行時引数を渡す事ができる - ただし、
func init()でflag.Parse()を行っていると、テスト時の実行順序の関係上、期待しない動きをする事がある。
testing
is 何
goのテストを書く際に利用する標準パッケージです。テストは go test コマンドで実行します。
一般的に、テスト対象となるファイル ex) main.go の末尾に _test.goを付けて ex) main_test.go テストコードを記載しています。
package main
func square(i int) int {
return i * i
}
func main() {}
package main
import "testing"
func Test_square(t *testing.T) {
tests := []struct {
name string
i int
want int
}{
{name: "1", i: 1, want: 1 },
{name: "2", i: 2, want: 4 },
{name: "3", i: 3, want: 9 },
{name: "4", i: 4, want: 16 },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := square(tt.i); got != tt.want {
t.Errorf("square() = %v, want %v", got, tt.want)
}
})
}
}
The Go Playgroundのデフォルトセットにも、サンプルがありますね。
flag
is 何
コマンドラインフラグを扱う際に利用する標準パッケージです。
./square -target=10 といった形で、 -targetに対して値を渡す形でコマンドを実行したい場合に利用します。
以下は、flag.IntVarを利用してコマンドラインフラグを指定し、flag.Parse()を実行することで指定した値を受け取り、パッケージ変数 i へ格納しています。
package main
import (
"flag"
"fmt"
"os"
)
var (
i int
)
func init() {
flag.IntVar(&i, "target", 0, "Enter an integer to the square (not 0)")
}
func square(i int) int {
return i * i
}
func run() int {
flag.Parse()
if i == 0 {
fmt.Println("input 0")
return 1
}
fmt.Println(square(i))
return 0
}
func main() {
os.Exit(run())
}
ユニットテストでflagへ引数を渡す
前述したソースの run() 関数をテストします。
テスト関数内で、 flag.Commandline.Set(name, value string) errorを利用します。
その後、テスト対象の run() 関数内で flag.Parse() が実行される事で、パッケージ変数に値を格納することができます。
func Test_run(t *testing.T) {
tests := []struct {
name string
i int
want int
}{
{name: "input0", i: 0, want: 1},
{name: "input1", i: 1, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
flag.CommandLine.Set("target", strconv.Itoa(tt.i)) // -target=iと指定したかの様に設定できる
if got := run(); got != tt.want {
t.Errorf("run() = %v, want %v", got, tt.want)
}
})
}
ユニットテスト時のハマリどころ
上記のflagの例ですが、サラっとinit()関数を利用しています。
こちらはパッケージの初期化の際に動く関数で、パッケージ変数の複雑な初期化などに利用します。 詳細は公式ドキュメントへ
今回はコマンドラインフラグ -targetを指定する必要があったため、init()関数に記載しています。
当初は以下の通り init()を記述していました。
func init() {
flag.IntVar(&i, "target", 0, "Enter an integer to the square (not 0)")
flag.Parse()
}
この様に記述した場合、後述する実行順序の関係で、下記の通りtestそのものが動作しなくなってしまいます。
$ go test
flag provided but not defined: -test.timeout
Usage of /var/folders/53/j681dgkd1wb7jjyvg8vkwf8h0000gp/T/go-build009487560/b001/aggreagtemyqiita.test:
-target int
Enter an integer to the square (not 0)
exit status 2
FAIL aggregate-my-qiita/cmd/aggreagtemyqiita 0.300s
エラー内容を見る限り、どうやらflagパッケージが関係していそうですね。
テストの実行順序について
こちらの記事に詳細が書かれています。
go testを実行する際は、メイン関数そのものを実行しテストフラグを付与する事で、テストを実行しているようです。
また、Go1.13リリースノートに以下の通り記載されていました。
Testing flags are now registered in the new Init function, which is invoked by the generated main function for the test. As a result, testing flags are now only registered when running a test binary, and packages that call flag.Parse during package initialization may cause tests to fail.
テスト用に生成されたメイン関数によって呼び出される新しい Init関数にテストフラグが登録されるようになりました。その結果、テストフラグはテストバイナリの実行時にのみ登録されるようになり、パッケージの初期化中に flag.Parse を呼び出すパッケージではテストが失敗する可能性があります。
つまり、私のハマったポイントとしては、init() 関数に flag.Parse() を記載してしまったため、該当のパッケージのテストにおいて、
テスト用のメイン関数のflagセットの前に flag.Parse() が実行されてしまったため、前述のエラーが出たと解釈しました。
間違っていたらご指摘ください。
おわりに
ハマった事で、 go testが何をしているか、Goの初期化から実行までの順序について学ぶ事ができました。
GoはGoで書かれているので、いずれソースコード上で上記の流れを追ってみたいと思います。