🌟

テスト関数内のdefer文に注意

2022/10/03に公開

テストの前処理と後処理

テストでは、一時ファイルやテスト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.Fatalruntime.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())
		})
	}
}

The Go Playgroundで実行する

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

The Go Playgroundで実行する

こうすると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())
		})
	}
}

The Go Playgroundで実行する

一時ディレクトリの作成時にエラーが発生した場合、テストが落ちるように作られているため、エラー処理なども省け、非常にシンプルに記述できます。もちろん、(*testing.T).TempDirメソッド内では、(*testing.T).Cleaupメソッドを用いて一時ディレクトリを削除するように記述されています。

おわりに

本記事では、テスト関数内でdefer文を用いる場合の注意点と、その代替として、(*testing.T).Cleaupメソッドや(*testing.T).TempDirメソッドを紹介しました。テスト関数の後処理にdefer文を使っても問題にならないパターンもありますが、特に理由がなければCleaupメソッドやTempDirメソッドを用いておくと良いでしょう。

Goのテストに関するノウハウはこの他にもたくさんありますが、それらについては他の記事や有償の講義で解説したいと思います。

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

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

  • 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