Go中級 #6 テスティング — testing パッケージと table-driven

読了 6分

#5 context 深掘り の次、今回は — Go の自慢の一つ。標準ライブラリに含まれる強力なテスティングツール

最初のテスト #

Go のテスティングは — 標準 testing パッケージと go test コマンドで動作します。追加ライブラリなしに。

add.go
package math

func Add(a, b int) int {
	return a + b
}
add_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
	got := Add(2, 3)
	want := 5
	if got != want {
		t.Errorf("Add(2,3) = %d, want %d", got, want)
	}
}

ルール:

  1. ファイル名が _test.go で終わる
  2. 関数名が Test で始まる、引数は *testing.T
  3. 同じパッケージ内に置ける(または package math_test で外部パッケージ)

実行 #

テスト実行
go test                  # 現在のパッケージ
go test ./...            # すべてのパッケージ
go test -v               # 詳細 (各テスト結果)
go test -run TestAdd     # 特定のテストのみ

go test -v がよく使われます — どのテストが実行されてどれが失敗したかが明確に見えます。

失敗を知らせる — t.Errorf vs t.Fatalf #

Error vs Fatal
func TestSomething(t *testing.T) {
	if !condition1 {
		t.Errorf("条件 1 失敗")    // 失敗を記録 + 次の行も実行
	}
	if !condition2 {
		t.Fatalf("条件 2 失敗")    // 失敗を記録 + 即時終了
	}
	// ...
}
Errorf / ErrorFatalf / Fatal
失敗の記録
その後のコード実行✗ (即時終了)
合う場面独立した検証(複数を一つのテストに)以後のコードが意味のない状況

普通 Fatal は — セットアップが失敗したか nil ポインタなど、それ以上進んでも意味のない場面で使います。

Table-Driven テスト #

同じ関数に複数の入力を検査したいとき — Go の最も有名なテストパターンです。

table-driven
func TestAdd(t *testing.T) {
	tests := []struct {
		name string
		a, b int
		want int
	}{
		{"正の数", 2, 3, 5},
		{"負の数", -1, -2, -3},
		{"混合", -5, 10, 5},
		{"ゼロ", 0, 0, 0},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got := Add(tc.a, tc.b)
			if got != tc.want {
				t.Errorf("Add(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
			}
		})
	}
}

肝心:

  1. ケースをスライスとして定義 — 新ケース追加が一行
  2. t.Run(name, ...) でサブテスト — 各ケースが独立に実行され報告される
  3. ケースごとに name があり どれが失敗したか即座に分かる

go test -v の結果:

=== RUN   TestAdd
=== RUN   TestAdd/正の数
=== RUN   TestAdd/負の数
=== RUN   TestAdd/混合
=== RUN   TestAdd/ゼロ
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/正の数 (0.00s)
    --- PASS: TestAdd/負の数 (0.00s)
    --- PASS: TestAdd/混合 (0.00s)
    --- PASS: TestAdd/ゼロ (0.00s)

特定のサブテストだけ実行 #

サブテストフィルタ
go test -run "TestAdd/正の数"

これがほぼすべての Go コードの標準テストの形です。ケースをデータで表現し、テストロジックは一箇所に

ヘルパー関数 — t.Helper() #

テストでよく使う検査を関数として切り出すと、失敗メッセージにヘルパー関数の位置が表示されてどこで失敗したか見にくくなります。

t.Helper()
func assertEqual(t *testing.T, got, want int) {
	t.Helper()    // この関数の呼び出し元の位置を表示
	if got != want {
		t.Errorf("got %d, want %d", got, want)
	}
}

func TestAdd(t *testing.T) {
	assertEqual(t, Add(2, 3), 5)
}

t.Helper() 一行を追加すれば — 失敗メッセージにヘルパー関数ではなく 呼び出し位置 が表示されます。ヘルパーをよく作るならほぼ必須。

セットアップとクリーンアップ — t.Cleanup #

テストごとにセットアップ/クリーンアップが必要なら。

setup / cleanup
func TestWithDB(t *testing.T) {
	db := setupDB(t)
	t.Cleanup(func() {
		db.Close()
	})

	// テスト本文
}

t.Cleanupテストが終わるとき自動実行 されます。成功/失敗に関係なく。defer と似ていますが — テストヘルパー関数でも動作します(ヘルパーが終わっても cleanup はテスト終了まで生きている)。

一時ディレクトリ — t.TempDir #

t.TempDir
func TestFileWrite(t *testing.T) {
	dir := t.TempDir()    // 自動的に整理される一時ディレクトリ

	path := filepath.Join(dir, "test.txt")
	os.WriteFile(path, []byte("hi"), 0644)

	// テストが終わると dir 自動削除
}

よく使われるヘルパー。直接 os.MkdirTempdefer os.RemoveAll を書くより短く安全です。

外部パッケージテスト — _test サフィックス #

同じフォルダのテストファイルは普通同じパッケージ名 — そうすると unexported な関数もテスト可能。ただし意図的に 外部から見る視点 でテストしたいとき:

外部パッケージテスト
// math/add_test.go
package math_test    // _test サフィックス

import (
	"testing"
	"path/to/math"   // 外部から import するように
)

func TestAdd(t *testing.T) {
	got := math.Add(2, 3)
	// ...
}

そうすると — exported API だけでテストするように強制されます。大きなライブラリでは二種類を混ぜて使うこともあります。

ベンチマーク — BenchmarkXxx #

ベンチマーク
func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Add(2, 3)
	}
}

ルール:

  1. 関数名が Benchmark で始まる、引数 *testing.B
  2. for i := 0; i < b.N; i++ の中に測定するコード
  3. b.N は Go が自動的に調整 — 反復回数が意味ある測定に十分になるまで
ベンチマーク実行
go test -bench=.
go test -bench=. -benchmem    # メモリ割り当ても測定
結果例
BenchmarkAdd-8          1000000000      0.30 ns/op

Add 一回が平均 0.30 ナノ秒かかったという意味。CPU コア数(8)も一緒に表示。

b.ResetTimer() — セットアップコストを除外 #

ResetTimer
func BenchmarkParse(b *testing.B) {
	data := generateBigData()    // セットアップ — 測定から除外
	b.ResetTimer()

	for i := 0; i < b.N; i++ {
		Parse(data)
	}
}

セットアップ時間が測定に入ると正確な結果が出ないので — セットアップ後に ResetTimer()

Example 関数 — ドキュメントでありテスト #

example
func ExampleAdd() {
	fmt.Println(Add(2, 3))
	// Output: 5
}

Example で始まる関数 + 末尾の // Output: コメント。Go の godoc/pkg.go.dev にコード例として表示され、同時にテストとしても実行 されます。Output が違うと失敗。

ライブラリのドキュメントを豊富にする標準ツールです。

モッキング — Go のアプローチ #

Go にはモッキングライブラリ(testify、gomock)もありますが — インターフェースをうまく使うだけで標準ツールだけで モッキングができます。

インターフェースでモッキング
type UserStore interface {
	GetUser(id string) (*User, error)
}

type Service struct {
	store UserStore
}

// 実際の実装
type DBStore struct{ /* ... */ }
func (s *DBStore) GetUser(id string) (*User, error) { /* DB 検索 */ }

// テスト用 fake
type fakeStore struct {
	users map[string]*User
}
func (s *fakeStore) GetUser(id string) (*User, error) {
	if u, ok := s.users[id]; ok {
		return u, nil
	}
	return nil, ErrNotFound
}

// テスト
func TestService(t *testing.T) {
	store := &fakeStore{
		users: map[string]*User{"u1": {Name: "カーティス"}},
	}
	svc := &Service{store: store}

	user, err := svc.LookupUser("u1")
	// ...
}

#1 インターフェース の暗黙的実装のおかげで — テスト用実装を自由に差し込めます。モッキングライブラリなしでも十分です。

httptest — HTTP テスト #

標準ライブラリに HTTP ハンドラをテストするツールがあります。

httptest.NewServer
import (
	"net/http"
	"net/http/httptest"
)

func TestHandler(t *testing.T) {
	handler := http.HandlerFunc(myHandler)
	server := httptest.NewServer(handler)
	defer server.Close()

	resp, err := http.Get(server.URL + "/api/users")
	// 検証
}

httptest.NewRecorder も — 本物のサーバを立ち上げずにハンドラだけ呼んで結果を検査できます。実践 #6 テストとデプロイ で詳しく。

カバレッジ — -cover #

カバレッジ
go test -cover
go test -coverprofile=cover.out
go tool cover -html=cover.out      # HTML で可視化

テストがどの行を実行したか — 一つのコマンドで。意外と軽量なツールです。

よく使うテストライブラリ — testify #

標準だけで十分ですが — assertion が長くなれば testify がよく使われます。

testify
import "github.com/stretchr/testify/assert"

func TestSomething(t *testing.T) {
	got := Add(2, 3)
	assert.Equal(t, 5, got)
	assert.NoError(t, err)
	assert.NotNil(t, result)
}

if/Errorf より短くて意図が明確。ただし標準だけでも十分動作するので、チームのコンベンションに合わせれば良いです。

まとめ #

今回の記事で整理した内容:

  • ファイル _test.go、関数 Test...、引数 *testing.T
  • t.Errorf(続行) vs t.Fatalf(即時中断)
  • table-driven + t.Run が Go の標準パターン
  • t.Helper() でヘルパー関数で呼び出し元の位置を表示
  • t.Cleanupt.TempDir のような自動クリーンアップツール
  • _test サフィックスパッケージで外部視点のテスト
  • Benchmark... + b.N + -bench=.
  • Example... はドキュメントでありテスト
  • インターフェース + fake で標準モッキング
  • httptest-cover のようなビルトインサポート

次の記事(#7 標準ライブラリツアー)では — Go の分厚い標準ライブラリの中でよく使われるもの(io、fmt、strings、time、encoding/json など)を一気に見渡します。

X