🧪

クロージャ内では(*testing.T).Fatalメソッドの呼び出しを避ける理由

2022/09/30に公開約4,300字

(*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")
}

The Go Playgroundで動かす

テストを書く際には、当たり前のように書いているのであまり違和感を感じないかもしれませんが、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")
}

The Go Playgroundで動かす

クロージャ内の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")
}

The Go Playgroundで動かす

テスト関数内でゴールーチンを使用することはあまりしないかもしれません。しかし、自分の書いたコードでゴールーチンを使っていなくても他のパッケージの関数などにクロージャを渡した場合には、それが別のゴールーチンで呼び出される可能性があります。

たとえば、次のように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のテストに関するノウハウはこの他にもたくさんありますが、それらについては他の記事や有償の講義で解説したいと思います。

https://tenntenn.connpass.com/event/261568/

満員御礼のため、DAY2も募集します。

https://tenntenn.connpass.com/event/262106/?z

ちなみに、有償の講義では以下のようなことを扱う予定です。

  • 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

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