Open4

[Go]func TestMain(m *testing.M)内で実行した前処理の結果によって、m.Runを実行させないようにしたい

testing.Tでできるけど、testing.Mでできないこと

func TestXXX(t *testing.T)の場合は、t.Fatalを呼ぶことにとってテストの実行をその場で止めることが可能です。

// (例)
func TestXXX(t *testing.T) {
        // テストの前処理が失敗したら、その時点でFAILさせる
        if err := setup(); err != nil {
		t.Fatal("setup fail")
        }

        // ここから先は、Fatalの際は実行されない
        got, err := XXX(input)
        if got != expected {
                t.Error("unexpected result")
        }
}

ですが、func TestMain(m *testing.M)を導入して前処理をそちらに移した場合には、同じことができません。
なぜなら、testing.MにはFatalメソッドが存在しないからです。

func TestMain(m *testing.M) {
        if err := setup(); err != nil {
		m.Fatal("setup fail") // これは不可能(mにFatalメソッドがない)
        }

	m.Run() // これを実行させないようにするにはどうしたらいい?
}

ダメな例

一つ考えられるのは、errがnot-nil値だった場合に即座にreturnさせるという方法です。
returnして関数が終了してしまえば、その後のm.Run()によるテスト実行は行われません。

func TestMain(m *testing.M) {
	err := setup()
	if err != nil {
		return
	}
	m.Run() // returnされているならこれは実行されない
}

ですが、これには「m.Run()が一切実行されない=一回もFAILしてない」という状態になるので、テスト結果としてはPASSとなってしまいます。
前処理に失敗しているのにテストがPASSになるというのは直感に反します。

よかった例

os.Exit()関数によって、終了コードを直接指定する形で関数を終わらせることで、前処理失敗時に「m.Run()を実行させない&テスト実行結果をFAILにする」という2つの要件を満たすことができます。

func TestMain(m *testing.M) {
	err := setup()
	if err != nil {
		os.Exit(1) // これならテスト結果はFAILになる
	}
	m.Run() // os.Exit(1)が呼ばれたならこれは実行されない
}

「Go1.15以前ではfunc TestMain(m *testing.M)終了時に明示的にos.Exit()を呼ばなければならなかった」といういにしえの知識から思いつきました。

これがベストな方法なのかはどうなんでしょう。情報求む。

気になってgolang/go配下を見に行ったところ、下記の3パターンを見付けました。

  1. 直接os.Exitするパターン (スクラップで紹介されているもの)
  2. m.Run()を実行する関数を分けて、 status code: 1 を早期リターンするパターン
  3. panicするパターン

1. 直接os.Exitするパターン

本スクラップで紹介されているものと同じで、m.Run()を実行するための条件が揃っていない時に、os.Exit(1)を呼ぶものです。

https://github.com/golang/go/blob/9d0819b27ca248f9949e7cf6bf7cb9fe7cf574e8/src/crypto/tls/handshake_test.go#L337
func runMain(m *testing.M) int {
	...
	// Set up localPipe.
	l, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		l, err = net.Listen("tcp6", "[::1]:0")
	}
	if err != nil {
		fmt.Fprintf(os.Stderr, "Failed to open local listener: %v", err)
		os.Exit(1)
	}
	...
	return m.Run()

直接os.Exitを呼ぶと書きましたが、実際には TestMain と分けて runMain や、 testMain といった関数を宣言し、その中で os.Exit を呼んでいるパターンしか無さそうでした。

2. m.Run()を実行する関数を分けて、 status code: 1 を早期リターンするパターン

intを返すrunMain、testMainといった関数を分けるパターンです。
ここでのTestMainは、この関数を実行した結果を使ってos.Exit()する実装になっているので、FAILさせたい時は1を早期リターンする形になります。

https://github.com/golang/go/blob/0625460f79eed41039939f957baceaff5e269672/src/cmd/vet/vet_test.go#L33
func TestMain(m *testing.M) {
	flag.Parse()
	os.Exit(runMain(m))
}

func testMain(m *testing.M) int {
	dir, err := os.MkdirTemp("", "vet_test")
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		return 1
	}
	...
	return m.Run()
}

これは素直な実装で読みやすそうです。
ただ、golang/go配下では思ったほど使われていませんでした。

3. panicするパターン

os.Exitせず、panicさせてしまうパターンです。

https://github.com/golang/go/blob/a040ebeb980d1a712509fa3d8073cf6ae16cbe78/misc/cgo/life/life_test.go#L22
https://github.com/golang/go/blob/13a4e8c41cd1d242a435d44e7f66f370e5306a8c/misc/cgo/testcarchive/carchive_test.go#L51
func testMain(m *testing.M) int {
	...
	var err error
	GOPATH, err = os.MkdirTemp("", "carchive_test")
	if err != nil {
		log.Panic(err)
	}
	...
	return m.Run()
}

手元で試したところ、TestMain中でのpanicでFAILが記録されたので、実はこれで十分なようです。
golang/go配下では、cgo関連の実装のtestに、log.Panicでpanicさせるパターンが複数含まれていました。
エラーの内容を記録した上で終了させたい時には、このやり方がちょうど良いかも知れません。

個人的には 2 推しですが、1, 3のパターンでも良さそうな感じがします。

2番の方法は思いつかなかったですね……
testing.Mを引数にとるテスト関数は一つだけ、という思い込みに囚われてしまってました。

ただ、「intを返すrunMain、testMain」という風にTestMainの中身の処理を分割させていくのが読みやすいどうかは人によるのかな、とは思います。
私個人の感想だとsyumaiさんと全く逆で、1,3が素直で2は手が込んでて小洒落てるなー、という印象です。

具体的な方法については個人差がありそうですね。
2 がわかりやすいと思ったのは、HTTP Serverのハンドラの実装などで、エラーコードを早期リターンすることに慣れているからかも知れません。

処理の分割についてはgolang/go配下では積極的に使われていて、直接TestMainの中でm.Run()を呼んでいるものはほぼ無さそうでした。
flagの処理だけをTestMain内で直接行って、setup / teardownは別の関数 (runMain など) で行うのがGo team内では主流のようです。
この辺り、他の人の使い方をちゃんと見たことが無かったので勉強になりました!

ログインするとコメントできます