Go テストパターン は何をしますか?
TDD手法に従い、信頼性が高く保守しやすいテストを書くための包括的なGoテストパターン。
アクティブにするタイミング
- 新しいGoの関数やメソッドを書くとき
- 既存のコードにテストカバレッジを追加するとき
- パフォーマンスが重要なコードのベンチマークを作成するとき
- 入力バリデーションのファズテストを実装するとき
- GoプロジェクトでTDDワークフローを実行するとき
GoのTDDワークフロー
RED-GREEN-REFACTORサイクル
RED → 最初に失敗するテストを書く
GREEN → テストを通すための最小限のコードを書く
REFACTOR → テストが通ったままコードを改善する
REPEAT → 次の要件に進む
Goでの段階的なTDD
// ステップ1: インターフェース/シグネチャの定義
// calculator.go
package calculator
func Add(a, b int) int {
panic("実装されていません") // プレースホルダ
}
// ステップ2: 失敗するテストを書く (RED)
// calculator_test.go
package calculator
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)
}
}
// ステップ3: テストを実行 - FAILを確認
// $ go test
// --- FAIL: TestAdd (0.00s)
// panic: 実装されていません
// ステップ4: 最小限のコードを実装 (GREEN)
func Add(a, b int) int {
return a + b
}
// ステップ5: テストを実行 - PASSを確認
// $ go test
// PASS
// ステップ6: 必要に応じてリファクタリングし、テストがまだ通ることを確認
テーブル駆動テスト
Goテストの標準パターン。最小限のコードで包括的なカバレッジを実現します。
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"正の数", 2, 3, 5},
{"負の数", -1, -2, -3},
{"ゼロ値", 0, 0, 0},
{"符号混合", -1, 1, 0},
{"大きな数", 1000000, 2000000, 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, got, tt.expected)
}
})
}
}
エラーケースを含むテーブル駆動テスト
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
input string
want *Config
wantErr bool
}{
{
name: "有効な設定",
input: `{"host": "localhost", "port": 8080}`,
want: &Config{Host: "localhost", Port: 8080},
},
{
name: "無効なJSON",
input: `{invalid}`,
wantErr: true,
},
{
name: "空の入力",
input: "",
wantErr: true,
},
{
name: "最小設定",
input: `{}`,
want: &Config{}, // ゼロ値の設定
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseConfig(tt.input)
if tt.wantErr {
if err == nil {
t.Error("エラーが期待されましたが、nilが返されました")
}
return
}
if err != nil {
t.Fatalf("予期しないエラー: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %+v; want %+v", got, tt.want)
}
})
}
}
サブテストとサブベンチマーク
関連テストの整理
func TestUser(t *testing.T) {
// 全サブテストで共有されるセットアップ
db := setupTestDB(t)
t.Run("作成", func(t *testing.T) {
user := &User{Name: "Alice"}
err := db.CreateUser(user)
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
if user.ID == "" {
t.Error("ユーザーIDが設定されている必要があります")
}
})
t.Run("取得", func(t *testing.T) {
user, err := db.GetUser("alice-id")
if err != nil {
t.Fatalf("GetUser failed: %v", err)
}
if user.Name != "Alice" {
t.Errorf("got name %q; want %q", user.Name, "Alice")
}
})
t.Run("更新", func(t *testing.T) {
// ...
})
t.Run("削除", func(t *testing.T) {
// ...
})
}
並列サブテスト
func TestParallel(t *testing.T) {
tests := []struct {
name string
input string
}{
{"case1", "input1"},
{"case2", "input2"},
{"case3", "input3"},
}
for _, tt := range tests {
tt := tt // 範囲変数をキャプチャ
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // サブテストを並列実行
result := Process(tt.input)
// アサーション...
_ = result
})
}
}
テストヘルパー
ヘルパー関数
func setupTestDB(t *testing.T) *sql.DB {
t.Helper() // この関数をヘルパー関数としてマーク
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("データベースのオープンに失敗しました: %v", err)
}
// テスト終了時にクリーンアップ
t.Cleanup(func() {
db.Close()
})
// マイグレーションを実行
if _, err := db.Exec(schema); err != nil {
t.Fatalf("スキーマの作成に失敗しました: %v", err)
}
return db
}
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("予期しないエラー: %v", err)
}
}
func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("got %v; want %v", got, want)
}
}
一時ファイルとディレクトリ
func TestFileProcessing(t *testing.T) {
// テンポラリディレクトリを作成 - 自動的にクリーンアップ
tmpDir := t.TempDir()
// テストファイルを作成
testFile := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("テストコンテンツ"), 0644)
if err != nil {
t.Fatalf("テストファイルの作成に失敗しました: %v", err)
}
// テストを実行
result, err := ProcessFile(testFile)
if err != nil {
t.Fatalf("ProcessFile failed: %v", err)
}
// アサート...
_ = result
}
ゴールデンファイル
testdata/に保存された期待される出力ファイルに対するテスト。
var update = flag.Bool("update", false, "ゴールデンファイルを更新")
func TestRender(t *testing.T) {
tests := []struct {
name string
input Template
}{
{"simple", Template{Name: "test"}},
{"complex", Template{Name: "test", Items: []string{"a", "b"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Render(tt.input)
golden := filepath.Join("testdata", tt.name+".golden")
if *update {
// ゴールデンファイルを更新: go test -update
err := os.WriteFile(golden, got, 0644)
if err != nil {
t.Fatalf("ゴールデンファイルの更新に失敗しました: %v", err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("ゴールデンファイルの読み取りに失敗しました: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("出力が一致しません:\ngot:\n%s\nwant:\n%s", got, want)
}
})
}
}
インターフェースを使用したモッキング
インターフェースベースのモッキング
// 依存関係のインターフェースを定義
type UserRepository interface {
GetUser(id string) (*User, error)
SaveUser(user *User) error
}
// プロダクション実装
type PostgresUserRepository struct {
db *sql.DB
}
func (r *PostgresUserRepository) GetUser(id string) (*User, error) {
// 実際のデータベースクエリ
}
// テスト用のモック実装
type MockUserRepository struct {
GetUserFunc func(id string) (*User, error)
SaveUserFunc func(user *User) error
}
func (m *MockUserRepository) GetUser(id string) (*User, error) {
return m.GetUserFunc(id)
}
func (m *MockUserRepository) SaveUser(user *User) error {
return m.SaveUserFunc(user)
}
// モックを使用したテスト
func TestUserService(t *testing.T) {
mock := &MockUserRepository{
GetUserFunc: func(id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, ErrNotFound
},
}
service := NewUserService(mock)
user, err := service.GetUserProfile("123")
if err != nil {
t.Fatalf("予期しないエラー: %v", err)
}
if user.Name != "Alice" {
t.Errorf("got name %q; want %q", user.Name, "Alice")
}
}
ベンチマーク
基本的なベンチマーク
func BenchmarkProcess(b *testing.B) {
data := generateTestData(1000)
b.ResetTimer() // セットアップ時間をカウントしない
for i := 0; i < b.N; i++ {
Process(data)
}
}
// 実行: go test -bench=BenchmarkProcess -benchmem
// 出力: BenchmarkProcess-8 10000 105234 ns/op 4096 B/op 10 allocs/op
異なるサイズでのベンチマーク
func BenchmarkSort(b *testing.B) {
sizes := []int{100, 1000, 10000, 100000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
data := generateRandomSlice(size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
// ソート済みデータのソートを避けるためにコピーを作成
tmp := make([]int, len(data))
copy(tmp, data)
sort.Ints(tmp)
}
})
}
}
メモリアロケーションベンチマーク
func BenchmarkStringConcat(b *testing.B) {
parts := []string{"hello", "world", "foo", "bar", "baz"}
b.Run("plus", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var s string
for _, p := range parts {
s += p
}
_ = s
}
})
b.Run("builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
_ = sb.String()
}
})
b.Run("join", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strings.Join(parts, "")
}
})
}
ファジング (Go 1.18以降)
基本的なファズテスト
func FuzzParseJSON(f *testing.F) {
// シードコーパスを追加
f.Add(`{"name": "test"}`)
f.Add(`{"count": 123}`)
f.Add(`[]`)
f.Add(`""`)
f.Fuzz(func(t *testing.T, input string) {
var result map[string]interface{}
err := json.Unmarshal([]byte(input), &result)
if err != nil {
// ランダム入力では無効なJSONが期待される
return
}
// パースが成功した場合、再エンコードが機能するはず
_, err = json.Marshal(result)
if err != nil {
t.Errorf("Unmarshal成功後にMarshalが失敗しました: %v", err)
}
})
}
// 実行: go test -fuzz=FuzzParseJSON -fuzztime=30s
複数の入力を持つファズテスト
func FuzzCompare(f *testing.F) {
f.Add("hello", "world")
f.Add("", "")
f.Add("abc", "abc")
f.Fuzz(func(t *testing.T, a, b string) {
result := Compare(a, b)
// プロパティ: Compare(a, a) は常に0を返すべき
if a == b && result != 0 {
t.Errorf("Compare(%q, %q) = %d; want 0", a, b, result)
}
// プロパティ: Compare(a, b) と Compare(b, a) は逆の符号を持つべき
reverse := Compare(b, a)
if (result > 0 && reverse >= 0) || (result < 0 && reverse <= 0) {
if result != 0 || reverse != 0 {
t.Errorf("Compare(%q, %q) = %d, Compare(%q, %q) = %d; 一貫性がありません",
a, b, result, b, a, reverse)
}
}
})
}
テストカバレッジ
カバレッジの実行
# 基本的なカバレッジ
go test -cover ./...
# カバレッジプロファイルの生成
go test -coverprofile=coverage.out ./...
# ブラウザでカバレッジを表示
go tool cover -html=coverage.out
# 関数ごとのカバレッジを表示
go tool cover -func=coverage.out
# レース検出付きカバレッジ
go test -race -coverprofile=coverage.out ./...
カバレッジ目標
| コードタイプ | 目標 |
|---|---|
| 重要なビジネスロジック | 100% |
| 公開API | 90%以上 |
| 一般的なコード | 80%以上 |
| 生成コード | 除外 |
生成コードをカバレッジから除外
//go:generate mockgen -source=interface.go -destination=mock_interface.go
// カバレッジプロファイルでビルドタグを使用して除外:
// go test -cover -tags=!generate ./...
HTTPハンドラテスト
func TestHealthHandler(t *testing.T) {
// リクエストを作成
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
// ハンドラを呼び出し
HealthHandler(w, req)
// レスポンスを確認
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("got status %d; want %d", resp.StatusCode, http.StatusOK)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "OK" {
t.Errorf("got body %q; want %q", body, "OK")
}
}
func TestAPIHandler(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "ユーザーを取得",
method: http.MethodGet,
path: "/users/123",
wantStatus: http.StatusOK,
wantBody: `{"id":"123","name":"Alice"}`,
},
{
name: "見つかりません",
method: http.MethodGet,
path: "/users/999",
wantStatus: http.StatusNotFound,
},
{
name: "ユーザーを作成",
method: http.MethodPost,
path: "/users",
body: `{"name":"Bob"}`,
wantStatus: http.StatusCreated,
},
}
handler := NewAPIHandler()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// リクエストを作成
var body io.Reader
if tt.body != "" {
body = strings.NewReader(tt.body)
}
req := httptest.NewRequest(tt.method, tt.path, body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// ハンドラを呼び出し
handler.ServeHTTP(w, req)
// レスポンスを確認
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("got status %d; want %d", resp.StatusCode, tt.wantStatus)
}
if tt.wantBody != "" {
bodyBytes, _ := io.ReadAll(resp.Body)
if string(bodyBytes) != tt.wantBody {
t.Errorf("got body %q; want %q", string(bodyBytes), tt.wantBody)
}
}
})
}
}
構文的なイディオム
テスト名の命名
// 良い命名
func TestUserCanLogin(t *testing.T) { /* ... */ }
func TestUserLoginWithInvalidPassword(t *testing.T) { /* ... */ }
func TestUserLogin_WhenUserIsLocked(t *testing.T) { /* ... */ }
// 避けるべき命名
func TestUser1(t *testing.T) { /* ... */ }
func TestFunctionality(t *testing.T) { /* ... */ }
func TestMyNewFeature(t *testing.T) { /* ... */ }
TestMain
func TestMain(m *testing.M) {
// 前処理
setup()
// テストを実行
code := m.Run()
// 後処理
teardown()
// 終了
os.Exit(code)
}
エラー vs 致命的エラー
t.Error("このテストケースは失敗しました") // 残りのサブテストを続行
t.Fatalf("致命的なエラー: %v", err) // このサブテストを即座に停止
t.Fatal("データベースに接続できません") // テストを完全に中止
// Fatalf は Fatal と同等 (フォーマット指定子付き)
データ駆動テスト (testdata ディレクトリ)
func TestParseFiles(t *testing.T) {
entries, err := os.ReadDir("testdata")
if err != nil {
t.Fatalf("testdata ディレクトリを読み取れません: %v", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
t.Run(entry.Name(), func(t *testing.T) {
data, err := os.ReadFile(filepath.Join("testdata", entry.Name()))
if err != nil {
t.Fatalf("ファイル %s を読み取れません: %v", entry.Name(), err)
}
result, err := ParseConfig(string(data))
if err != nil {
t.Fatalf("ファイル %s のパースに失敗しました: %v", entry.Name(), err)
}
// 結果をアサート...
_ = result
})
}
}
クイックリファレンス
| コマンド | 説明 |
|---|---|
go test | 現在のパッケージのテストを実行 |
go test ./... | 全パッケージのテストを実行 |
go test -v | 詳細な出力付きでテストを実行 |
go test -run TestName | 特定のテストのみを実行 |
go test -bench=. | すべてのベンチマークを実行 |
go test -cover | カバレッジ付きでテストを実行 |
go test -fuzz=FuzzName | 特定のファズテストを実行 |
go test -race | レース検出器を有効化 |
go test -count=1 | キャッシュを無効化(クリーンラン) |
go test -timeout=30s | タイムアウトを設定 |
go test -short | 長時間実行テストをスキップ |
go tool cover -html=c.out | HTML形式でカバレッジレポートを生成 |
ベストプラクティス
- テストを冪等にする - 各テストは独立して実行可能でなければなりません
- テストに名前を付ける - テーブル駆動テストでは説明的な名前を使用する
- t.Helper() を使用する - ヘルパー関数のエラーが正しい行を指すようにする
- t.Cleanup() を優先する - クリーンアップをセットアップの近くに配置し、defer よりも推奨
- t.TempDir() を使用する - テストファイルの自動クリーンアップ
- 避けるべきもの: テストの共有状態、ネットワークコール(単体テスト)、ファイルシステムへの副作用、データベース接続(単体テスト)
- テストはアサーションで終了させること - テストの最後にアサーションがない場合は避ける
- テーブル駆動テストを使用する - 同じロジックの複数の類似テストを避ける
- 本番コードをテスト用に変更しない - テスト可能性のために公開範囲を広げない(代わりに internal テストパッケージを使用する)
- エクスポートされていない関数をテストする - 必要な場合は
_testパッケージを使用するのではなく、同じパッケージ内でテストする