Go 1.15 testing パッケージの TestMain は os.Exit を呼ばなくてよくなった
TL;DR
- Go1.15 がリリースされました
- testing パッケージの実装の変更にて TestMain で os.Exit を呼ばなくてよくなりました
- testing.M の実装変更及び、 go test で生成される main 関数のテンプレート修正によって実現されている
Go 1.15
2020年8月11日、 The Go Blog より、 Go 1.15 のリリースが公表されました。
Release Note を読むと、さまざまな機能が追加されています。
これを読んでいて、ふと Minor changes to the library から testing パッケージにいくつか変更があって、バージョンアップしたらちょっっっとテストの書き方が変わるなって思ったので深堀り確認してみました。
※ 注記: Qiitaで同じものを見たとしたら同一の記事です。 Go 1.15 testing パッケージの TestMain は os.Exit を呼ばなくてよくなった - Qiita ブログの書き先を迷子になっています。
testing の変更
testing パッケージには、いくつかマイナーな変更が加えられています。
https://golang.org/doc/go1.15#testing
- T.Deadline の追加
The testing.T type now has a Deadline method that reports the time at which the test binary will have exceeded its timeout.
- TestMain は os.Exit を呼ばなくてよくなった
A TestMain function is no longer required to call os.Exit. If a TestMain function returns, the test binary will call os.Exit with the value returned by m.Run.
- T.TempDir / B.TempDir の追加
The new methods T.TempDir and B.TempDir return temporary directories that are automatically cleaned up at the end of the test.
- go test -v の表示改善
go test -v now groups output by test name, rather than printing the test name on each line.
タイトルのとおりですが、この中の 2 つめに焦点を当ててみます。
TestMain は os.Exit を呼ばなくてよくなった
A TestMain function is no longer required to call os.Exit. If a TestMain function returns, the test binary will call os.Exit with the value returned by m.Run.
https://golang.org/doc/go1.15#testing
たとえば、TestMain を使うテストコードはGo 1.14 以前は次のようなコードだったと思います。
package main_test import ( "log" "os" "testing" ) func TestMain(m *testing.M) { shutdown := setupDB() c := m.Run() shutdown() os.Exit(c) } func setupDB() func() { return func() { // shutdown } }
testing.m.Run() は プログラムの終了コード に該当する int 値を返してくるので、それを os.Exit() に明示的に渡すようにしていました。
https://golang.org/pkg/testing/#M.Run
Go 1.15 以降は、この os.Exit
が不要になりました。
package main_test import ( "testing" ) func TestMain(m *testing.M) { shutdown := setupDB() m.Run() shutdown() } func setupDB() func() { return func() { // shutdown } }
使い手としてはこれだけです。マイナーな変更ですね。まぁそれだけでは、へぇそうなんだねって話なので、実際中ではどういう変更があったのってところを見たいなと思います。
Dive into testing.M
Issue
この変更は、次の issue で 2019年9月6日に提案されているものです。
issue を読んでいると、よく Buggy なテストが生まれることが観測されていたと、Russ Cox氏が言及しています。
This mistake does happen a lot, and the testing package that called TestMain and prepared the m can certainly record the result of m.Run for use in its own outer os.Exit.
https://github.com/golang/go/issues/34129#issuecomment-531011027
実際にどういうミスかと言うと、こういうテストコードの場合です。
package sample_test import ( "os" "testing" ) func TestMain(m *testing.M) { var exitCode int defer os.Exit(exitCode) setup() defer teardown() exitCode = m.Run() } func setup() { // } func teardown() { // } func TestHoge(t *testing.T) { t.Fail() }
TestMain() では、deferでos.Exit をcallするようにしています。そして当該パッケージ内には失敗するテストコードがあるので、これは失敗するはずです。これを実行すると次のようになります。
$ go test -v hoge_test.go === RUN TestHoge --- FAIL: TestHoge (0.00s) FAIL ok command-line-arguments 0.369s
ok
となってしまいました。終了コードがうまく反映されていません。
このような失敗していてもテストコマンドが静かにそのバグを報告しなくてなってしまうことに対して、 Accept されたのは以下でした。
- m.Run records its own result in an unexported field of m, and then
- if TestMain(m) returns, the outer test func main harness calls os.Exit with that code.
1. m.Run records its own result in an unexported field of m
この課題に対して実際に実装された結果は 下記の commit から見て取れます。
https://go-review.googlesource.com/c/go/+/219639/10/src/testing/testing.go
L220 にてコメントは次のように変更されています。
src/testing/testing.go
// and teardown is necessary around a call to m.Run. m.Run will return an exit // status that may be passed to os.Exit. If TestMain returns, the test wrapper // will pass the result of m.Run to os.Exit itself. When TestMain is called, // flag.Parse has not been run. If TestMain depends on command-line flags, // including those of the testing package, it should call flag.Parse explicitly.
If TestMain returns, the test wrapper will pass the result of m.Run to os.Exit itself.
TestMain が return したら、 the test wrapperさんがそれをos.Exitに渡してくれるというものです。 そのためにここでは、 「1. testing.M の構造体に、終了コードが追加」し、「2. testing.M.Run は M.exitCode に終了コードを設定」しています。
- testing.M の構造体に、終了コードが追加された。
src/testing/testing.go
// M is a type passed to a TestMain function to run the actual tests. type M struct { deps testDeps tests []InternalTest benchmarks []InternalBenchmark examples []InternalExample timer *time.Timer afterOnce sync.Once numRun int // value to pass to os.Exit, the outer test func main // harness calls os.Exit with this code. See #34129. exitCode int // <= これが今回増えました }
- testing.M.Run は M.exitCode に終了コードを設定する
return する際、これまで終了コードを返していただけでしたが、 M.exitCode に設定するようにしています。
src/testing/testing.go
if *parallel < 1 { fmt.Fprintln(os.Stderr, "testing: -parallel can only be given a positive integer") flag.Usage() // return 2 これがもともと、普通に終了コードを返答していた m.exitCode = 2 return }
※ 関数冒頭に defer func で m.exitCode を戻り値として返却するように記述しています。
src/testing/testing.go
func (m *M) Run() (code int) { defer func() { code = m.exitCode }()
2. if TestMain(m) returns, the outer test func main harness calls os.Exit with that code.
TestMain が終了後、外の main 関数が m.exitCode を見て、os.Exit をcallするようにしています。これは、以下のコミットにその反映を見ることが出来ます。
https://go-review.googlesource.com/c/go/+/219639/10/src/cmd/go/internal/load/test.go
この変更は、 go test
によって生成される main 関数のテンプレートの修正です。
TestMain の場合は、 reflect パッケージを追加し、 構造体 M の exitCode の field をとっています。
src/cmd/go/internal/load/test.go
os.Exit(int(reflect.ValueOf(m).Elem().FieldByName("exitCode").Int()))
この変更によって、testing.M の内部の unexported な exitCode の値を使って、 os.Exit をコールしています。
以上
Go 1.15 におけるテストの書き方の変更と、その背景・内部実装の深堀りでした。 Congrats Go 1.15 release!!!