Software engineering from east direction

六本木一丁目で働くソフトウェアエンジニアのブログ

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 のリリースが公表されました。

blog.golang.org

Release Note を読むと、さまざまな機能が追加されています。 

golang.org

これを読んでいて、ふと 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日に提案されているものです。

github.com

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 されたのは以下でした。

  1. m.Run records its own result in an unexported field of m, and then
  2. 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 に終了コードを設定」しています。

  1. 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 // <= これが今回増えました
}
  1. 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!!!