クロージャ内では(*testing.T).Fatalメソッドの呼び出しを避ける理由
(*testing.T).Fatalメソッドはruntime.Goexit()を呼ぶ
Goのテストは*testing.T
型を引数にとり名前がTest
で始まる関数をテスト関数とします。*testing.T
型は、t.Error
メソッドやt.Fatal
メソッドのようなテストを理由とともに失敗させるためのメソッドをいくつか用意しています。
名前にError
がつくメソッドは、テストを指定したメッセージとともに失敗させます。しかし、テストはそのまま継続させます。一方、名前にFatal
がつくメソッドは、Error
系のメソッドと同様にテストを失敗させるだけではなく、それに加え処理をその時点で終了させます。
たとえば、次のようなテスト関数があった場合、t.Error("error")
は処理を続けるため、次のt.Fatal("fatal")
が実行されますが、Fatal
メソッドは処理を止めるため、t.Log("end of function")は実行されません。ただし、
defer t.Log("defer")`で仕込んだ、関数呼び出しは実行されます。
func TestSample(t *testing.T) {
defer t.Log("defer")
t.Error("error") // 処理は続けられる
t.Fatal("fatal") // 処理が止まる
t.Log("end of function")
}
テストを書く際には、当たり前のように書いているのであまり違和感を感じないかもしれませんが、Fatal
系のメソッドは、return
文もなしにどのように処理を止めているのでしょうか?
実はFatal
系のメソッドやFailNow
メソッドなどは、runtime.Goexit
関数を用いることで処理を止めること実現しています。runtime.Goexit
関数はpanic
関数を呼んだときのように、関数の実行を直ちに終了し、defer
文で仕掛けられた関数呼び出しを実行後に関数を終了します。ただし、パニックが発生するわけではないので、プログラムがクラッシュしたり、recover
関数で回復する必要はありません。
たとえば、次のようなコードは、ゴールーチンの中でruntime.Goexit
関数を呼んでいます。そのため、defer
文で仕込まれたfmt.Println("defer")
やゴールーチンの処理が終わったあとに実行されるfmt.Println("B")
などは実行されますが、関数の末尾にあるfmt.Println("A")
は実行されずに、関数を終了します。
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer func() {
fmt.Println("defer")
wg.Done()
}()
runtime.Goexit()
fmt.Println("A")
}()
wg.Wait()
fmt.Println("B")
}
クロージャ内のFatalを避ける
(*testing.T).Fatal
メソッドなどは、内部でruntime.Goexit
関数を呼び出すため、テスト関数内でゴールーチンを別途作成するときには注意が必要です。
たとえば、次のようにテスト関数内部で作成したゴールーチン内でFatal
メソッドを呼び出すと、そのゴールーチンで動いている関数は終了しますが、テスト関数は終了しません。そのため、このテストを実行するとt.Fatal("Fatal")
でテスト関数は終了されず、t.Log("B")
を実行してしまいます。
func TestSample(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer func() {
t.Log("defer")
wg.Done()
}()
t.Fatal("Fatal")
t.Log("A")
}()
wg.Wait()
t.Log("B")
}
テスト関数内でゴールーチンを使用することはあまりしないかもしれません。しかし、自分の書いたコードでゴールーチンを使っていなくても他のパッケージの関数などにクロージャを渡した場合には、それが別のゴールーチンで呼び出される可能性があります。
たとえば、次のようにHTTPサーバのテストでよく使用するnet/http/httptest
パッケージを利用することを考えてみます。
func TestSample(t *testing.T) {
var wg sync.WaitGroup
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer wg.Done()
if http.MethodPost != r.Method {
t.Fatal("invalid method", r.Method)
}
}))
t.Cleanup(ts.Close)
wg.Add(1)
http.Get(ts.URL)
wg.Wait()
t.Log("end of function")
}
※ The Go Playgroundでは動作しない
httptest
パッケージで提供されているNewServer
関数には、引数としてhttp.Handler
インタフェースを実装する型の値を指定する必要があります。テストの場合は、クロージャをhttp.HandlerFunc
型にキャストして渡すことが多いでしょう。
HTTPハンドラの呼び出しは新しいゴールーチンを作成して行われるため、NewServer
関数に引数として指定したクロージャは別のゴールーチンで実行されます。ハンドラの中で値をチェックして、場合によってはエラーが発生するような変換を行い、エラーの場合にFatal
メソッドでテストを落としたくなるかもしれません。しかし、前述の通りFatal
メソッドは、runtime.Goexit
関数を内部で呼んでいるため、テスト関数のゴールーチンではなく、HTTPハンドラのゴールーチンを終了してしまいます。
そのため、このテストを実行すると次のように、end of function
も表示されてしまいます。
$ go test -v
=== RUN TestSample
main_test.go:16: invalid method GET
main_test.go:24: end of function
--- FAIL: TestSample (0.01s)
FAIL
基本的には、テスト関数内でFatal
系のメソッドを呼び出すようにし、クロージャ内では呼び出さないようにしておくと、想定外の動きをしなくなります。
おわりに
本記事では、(*testing.T).Fatal
メソッドを別のゴールーチン内で呼び出すと想定外の動きをすることを解説しました。Goのテストに関するノウハウはこの他にもたくさんありますが、それらについては他の記事や有償の講義で解説したいと思います。
満員御礼のため、DAY2も募集します。
ちなみに、有償の講義では以下のようなことを扱う予定です。
- Goのテスト基礎
- テストを行う必要性
- テスト関数
- Exampleテスト
- go testの基礎
- テスティングフレームワーク
- go-cmp
- go testでテストが動くしくみ
- カバレッジ
- テストテクニック
- テーブル駆動テスト
- テーブル駆動テストの基礎
- テストヘルパー
- 抽象化とモック
- GoMock
- moq
- tenntenn/testtime
- データベース
- 非公開な機能のテスト
- テストパッケージ
- 非公開な変数やフィールドの公開と設定
- 非公開な関数(メソッド)の呼び出し
- 非公開な型の公開
- 並列テスト
- t.Parallel
- テストケースのShadowing
- モックの差し替え
- 並行処理のテスト
- Race detector
- ゴールーチンリーク
- Contextを使ったテスト
- コマンドラインツールのテスト
- t.Cleanup
- t.TmpDir
- ゴールデンファイルテスト
- txtar
- tenntenn/golden
- test script
- シナリオテスト
- senarigo
- テーブル駆動テスト
- ベンチマーク
- Fuzzing
Discussion