テスト関数内のdefer文に注意
テストの前処理と後処理
テストでは、一時ファイルやテストDBのデータなど作成や削除など、前処理や後処理をしたくなる場合があります。各パッケージごとにテストの前処理と後処理を記述したい場合は、次のようにTestMain
関数を用意します。(*testing.M).Run
メソッドを実行する前後に前処理および後処理が書けます。
func TestMain(m *testing.M) {
fmt.Println("setup")
code := m.Run()
fmt.Println("tear-down")
os.Exit(code)
}
func Test1(t *testing.T) {}
func Test2(t *testing.T) {}
The Go Playgroundでは実行できません。
テスト関数であるTest1
関数とTest2
関数を用意すると、テストが開始される前にsetup
、そのパッケージのすべてのテスト関数が終了したあとにtear-down
が表示されます。実行結果は次のようになります。
$ go test -v
setup
=== RUN Test1
--- PASS: Test1 (0.00s)
=== RUN Test2
--- PASS: Test2 (0.00s)
PASS
tear-down
各テストごとに前処理と後処理を記述したい場合は、(*testing.T).Run
メソッドを用いてサブテストとして実行すると良いでしょう。次のように、テーブル駆動テストやテストヘルパーも合わせて用いると記述しやすいです。
func Test(t *tesint.T) {
t.Parallel()
cases := map[string]struct{
// テストケース項目
}{
// テストケース
}
for name, tt := range cases {
name, tt := name, tt
t.Run(name, func(t *testing.T) {
t.Parallel()
setup(t)
// (略)
teardown(t)
})
}
}
// 前処理
// エラーが発生したらt.Fatalなどで落とす
func setup(t *tesing.T) {
t.Helper()
// (略)
}
// 後処理
// エラーが発生したらt.Fatalなどで落とす
func teardown(t *tesing.T) {
t.Helper()
// (略)
}
テスト関数とdefer文
後処理の場合、テストがt.Fatal
などでテストを落とされたどうかに関わらず実行したい場合があります。次のように、前処理の後にすぐにdefer
文で後処理の実行を予約しておけば、t.Fatal
でruntime.Goexit
関数が呼ばれ、テスト関数が直ちに終了してもdefer
文で仕掛けた関数呼び出しは実行されます。
t.Run(name, func(t *testing.T) {
t.Parallel()
setup(t)
defer teardown(t)
// (略)
})
しかし、defer
文はあくまで関数が終了したときに呼ばれるように設定するだけで、サブテストを含めたテストがすべて終了した場合に実行されるとは限りません。たとえば、次のようなテスト関数の始まりで一時ディレクトリを作成し、各サブテストでそのディレクトリ内にファイルを作成するようなテストを考えてみます。テスト関数終了時に一時ディレクトリを削除するように、defer
文でos.RemoveAll
関数を実行しています。
package main
import (
"os"
"path/filepath"
"testing"
)
func Test(t *testing.T) {
t.Parallel()
tmpdir, err := os.MkdirTemp("", t.Name())
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpdir)
cases := map[string]struct {
msg string
}{
"hello": {"hello"},
"hi": {"hi"},
}
for name, tt := range cases {
name, tt := name, tt
t.Run(name, func(t *testing.T) {
t.Parallel()
fname := filepath.Join(tmpdir, name)
err := os.WriteFile(fname, []byte(tt.msg), 0666)
if err != nil {
t.Fatal(err)
}
t.Log("DONE", t.Name())
})
}
}
(*testing.T).Run
メソッドは、第2引数で指定した関数内で(*testing.T).Parallel
メソッドを呼んでいる場合、サブテストの終了を待たずに次の処理に移ります。つまり、次のサブテストを呼び出していき、テストケースがすべてなくなると、親のテスト関数を抜けてしまいます。そのため、defer
文でos.RemoveAll
関数を呼んでいると、次のようなエラーが発生するでしょう。
open /tmp/Test3908716383/hi: no such file or directory
サブテストで一時ディレクトリを使おうとした時点で一時ディレクトリがdefer
文で実行されたos.RemoveAll
関数によって削除されているからです。
(*testing.T).Cleaupメソッドを用いる
テストで後処理をしたい場合には、特別な理由がない場合には(*testing.T).Cleanup
関数を用いましょう。テスト関数の終了時ではなく、サブテストを含めたテストのすべてが終了した後に実行されます。defer
文と同じように、最後にCleaup
メソッドの呼び出しで指定した関数が最初に実行されます。
一時ファイルを削除する後処理はCleaup
メソッドを用いると、次のように記述できます。
package main
import (
"os"
"path/filepath"
"testing"
)
func Test(t *testing.T) {
t.Parallel()
tmpdir, err := os.MkdirTemp("", t.Name())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
os.RemoveAll(tmpdir)
})
cases := map[string]struct {
msg string
}{
"hello": {"hello"},
"hi": {"hi"},
}
for name, tt := range cases {
name, tt := name, tt
t.Run(name, func(t *testing.T) {
t.Parallel()
fname := filepath.Join(tmpdir, name)
err := os.WriteFile(fname, []byte(tt.msg), 0666)
if err != nil {
t.Fatal(err)
}
t.Log("DONE", t.Name())
})
}
}
こうするとParallel
メソッドを呼び出しているかに依存せず、後処理がうまく実行されます。
(*testing.T).TempDirメソッドを用いる
テスト開始時に一時ディレクトリを生成して、終了時に削除するパターンはよくあります。そのため、testing
パッケージでは、(*testing.T).TempDir)
メソッドが用意されています。TempDir
メソッドを用いると、次のようにシンプルに記述できます。
package main
import (
"os"
"path/filepath"
"testing"
)
func Test(t *testing.T) {
t.Parallel()
tmpdir := t.TempDir()
cases := map[string]struct {
msg string
}{
"hello": {"hello"},
"hi": {"hi"},
}
for name, tt := range cases {
name, tt := name, tt
t.Run(name, func(t *testing.T) {
t.Parallel()
fname := filepath.Join(tmpdir, name)
err := os.WriteFile(fname, []byte(tt.msg), 0666)
if err != nil {
t.Fatal(err)
}
t.Log("DONE", t.Name())
})
}
}
一時ディレクトリの作成時にエラーが発生した場合、テストが落ちるように作られているため、エラー処理なども省け、非常にシンプルに記述できます。もちろん、(*testing.T).TempDir
メソッド内では、(*testing.T).Cleaup
メソッドを用いて一時ディレクトリを削除するように記述されています。
おわりに
本記事では、テスト関数内でdefer
文を用いる場合の注意点と、その代替として、(*testing.T).Cleaup
メソッドや(*testing.T).TempDir
メソッドを紹介しました。テスト関数の後処理にdefer
文を使っても問題にならないパターンもありますが、特に理由がなければCleaup
メソッドやTempDir
メソッドを用いておくと良いでしょう。
Goのテストに関するノウハウはこの他にもたくさんありますが、それらについては他の記事や有償の講義で解説したいと思います。
ちなみに、有償の講義では以下のようなことを扱う予定です。
- 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