Goユニットテスト: 構造とベストプラクティス

Goのテスト入門から高度なパターンまで

目次

Goの組み込みテストパッケージ は、外部依存なしでユニットテストを書くための強力でミニマリストなフレームワークを提供しています。 ここでは、テストの基本、プロジェクト構造、そして信頼性の高いGoアプリケーションを構築するための高度なパターンについて説明します。

Goのユニットテストは素晴らしい

Goにおけるテストの重要性

Goの哲学は、シンプルさと信頼性を重視しています。標準ライブラリにはtestingパッケージが含まれており、ユニットテストはGoエコシステムにおいて一等市民の地位を占めています。よくテストされたGoコードは、保守性を高め、バグを早期に検出し、例を通じてドキュメント化します。Goに初めて触れる場合は、Goのチートシートを確認して、言語の基本をすぐに確認してください。

Goテストの主な利点:

  • 組み込みサポート: 外部フレームワークは不要
  • 高速実行: 並列実行がデフォルト
  • シンプルな構文: ボイラープレートコードが最小限
  • 豊富なツール: カバレッジレポート、ベンチマーク、プロファイリング
  • CI/CD対応: 自動パイプラインとの簡単な統合

Goテストのプロジェクト構造

Goのテストは、明確な命名規則とともに本番コードと一緒になります:

myproject/
├── go.mod
├── main.go
├── calculator.go
├── calculator_test.go
├── utils/
│   ├── helper.go
│   └── helper_test.go
└── models/
    ├── user.go
    └── user_test.go

重要な規約:

  • テストファイルは_test.goで終わる
  • テストはコードと同じパッケージに属するか、ブラックボックスタイプテストのために_test接尾辞を使用
  • 各ソースファイルには対応するテストファイルがある

パッケージテストアプローチ

ホワイトボックステスト(同じパッケージ):

package calculator

import "testing"
// 非公開関数や変数にアクセス可能

ブラックボックステスト(外部パッケージ):

package calculator_test

import (
    "testing"
    "myproject/calculator"
)
// みたびに公開された関数のみにアクセス可能(パブリックAPI向けに推奨)

基本的なテスト構造

すべてのテスト関数はこのパターンに従います:

package calculator

import "testing"

// テスト関数は"Test"で始まる
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5
    
    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

Testing.Tメソッド:

  • t.Error() / t.Errorf(): テストを失敗とマークするが、継続
  • t.Fatal() / t.Fatalf(): テストを失敗とマークし、直ちに停止
  • t.Log() / t.Logf(): ログ出力(-vフラグで表示)
  • t.Skip() / t.Skipf(): テストをスキップ
  • t.Parallel(): 他の並列テストと並列実行

テーブル駆動テスト: Goの方法

テーブル駆動テストは、複数のシナリオをテストするためのGoの慣用的な方法です。Goジェネリクスを使用して、さまざまなデータ型で動作する型安全なテストヘルパーを作成することもできます:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"加算", 2, 3, "+", 5, false},
        {"減算", 5, 3, "-", 2, false},
        {"乗算", 4, 3, "*", 12, false},
        {"除算", 10, 2, "/", 5, false},
        {"ゼロによる除算", 10, 0, "/", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Calculate(tt.a, tt.b, tt.op)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if result != tt.expected {
                t.Errorf("Calculate(%d, %d, %q) = %d; want %d", 
                    tt.a, tt.b, tt.op, result, tt.expected)
            }
        })
    }
}

利点:

  • 複数のシナリオにわたる単一のテスト関数
  • 新しいテストケースを簡単に追加
  • 期待される動作の明確なドキュメント
  • テストの整理と保守性の向上

テストの実行

基本コマンド

# 現在のディレクトリのテストを実行
go test

# 詳細な出力を表示
go test -v

# すべてのサブディレクトリのテストを実行
go test ./...

# 特定のテストを実行
go test -run TestAdd

# パターンに一致するテストを実行
go test -run TestCalculate/addition

# 並列実行(デフォルトはGOMAXPROCS)
go test -parallel 4

# タイムアウト付きでテストを実行
go test -timeout 30s

テストカバレッジ

# カバレッジ付きでテストを実行
go test -cover

# カバレッジプロファイルを生成
go test -coverprofile=coverage.out

# ブラウザでカバレッジを表示
go tool cover -html=coverage.out

# 関数ごとのカバレッジを表示
go tool cover -func=coverage.out

# カバレッジモードを設定(set, count, atomic)
go test -covermode=count -coverprofile=coverage.out

有用なフラグ

  • -short: if testing.Short()チェック付きでテストを実行
  • -race: レース検出器を有効(並列アクセスの問題を発見)
  • -cpu: GOMAXPROCS値を指定
  • -count n: 各テストをn回実行
  • -failfast: 最初のテスト失敗で停止

テストヘルパーとセットアップ/テアダウン

ヘルパー関数

t.Helper()でヘルパー関数をマークして、エラーレポートを改善します:

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // この行は呼び出し元として報告
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMath(t *testing.T) {
    result := Add(2, 3)
    assertEqual(t, result, 5) // エラーラインはここを指す
}

セットアップとテアダウン

func TestMain(m *testing.M) {
    // セットアップコード
    setup()
    
    // テストを実行
    code := m.Run()
    
    // テアダウンコード
    teardown()
    
    os.Exit(code)
}

テストフィクスチャ

func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("テストケースのセットアップ")
    return func(t *testing.T) {
        t.Log("テストケースのテアダウン")
    }
}

func TestSomething(t *testing.T) {
    teardown := setupTestCase(t)
    defer teardown(t)
    
    // テストコード
}

モックと依存性注入

インターフェースベースのモック

データベースとやり取りするコードをテストする際、インターフェースを使用することでモック実装の作成が容易になります。PostgreSQLでGoを使用している場合は、Go ORMの比較を確認して、テスト性の高いデータベースライブラリを選択してください。

// 本番コード
type Database interface {
    GetUser(id int) (*User, error)
}

type UserService struct {
    db Database
}

func (s *UserService) GetUserName(id int) (string, error) {
    user, err := s.db.GetUser(id)
    if err != nil {
        return "", err
    }
    return user.Name, nil
}

// テストコード
type MockDatabase struct {
    users map[int]*User
}

func (m *MockDatabase) GetUser(id int) (*User, error) {
    if user, ok := m.users[id]; ok {
        return user, nil
    }
    return nil, errors.New("user not found")
}

func TestGetUserName(t *testing.T) {
    mockDB := &MockDatabase{
        users: map[int]*User{
            1: {ID: 1, Name: "Alice"},
        },
    }
    
    service := &UserService{db: mockDB}
    name, err := service.GetUserName(1)
    
    if err != nil {
        t.Fatalf("予期しないエラー: %v", err)
    }
    if name != "Alice" {
        t.Errorf("got %s, want Alice", name)
    }
}

人気のあるテストライブラリ

Testify

アサーションとモックに最も人気のあるGoテストライブラリ:

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestWithTestify(t *testing.T) {
    result := Add(2, 3)
    assert.Equal(t, 5, result, "they should be equal")
    assert.NotNil(t, result)
}

// モックの例
type MockDB struct {
    mock.Mock
}

func (m *MockDB) GetUser(id int) (*User, error) {
    args := m.Called(id)
    return args.Get(0).(*User), args.Error(1)
}

他のツール

  • gomock: Googleのコード生成付きモッキングフレームワーク
  • httptest: HTTPハンドラのテスト用標準ライブラリ
  • testcontainers-go: Dockerコンテナを使用した統合テスト
  • ginkgo/gomega: BDDスタイルのテストフレームワーク

外部サービス(例: AIモデル)との統合テストでは、これらの依存関係をモックまたはスタブする必要があります。Ollama in Goを使用している場合、インターフェースラッパーを作成して、テスト可能なコードを構築することを検討してください。

ベンチマークテスト

Goにはベンチマークのための組み込みサポートがあります:

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

// ベンチマークを実行
// go test -bench=. -benchmem

出力は秒あたりの反復回数とメモリ割り当てを表示します。

ベストプラクティス

  1. テーブル駆動テストを書く: 複数のテストケースにスライス構造体パターンを使用
  2. t.Runでサブテストを使用: より良い整理と選択的なサブテスト実行
  3. まずエクスポートされた関数をテスト: 公開APIの動作に焦点を当てる
  4. テストをシンプルに保つ: 各テストは1つのことを検証
  5. 意味のあるテスト名を使用: 何をテストし、期待される結果を説明
  6. 実装詳細をテストしない: 行動をテストし、内部をテストしない
  7. インターフェースを使用して依存性を注入: モックが簡単になる
  8. 高カバレッジを目指すが、質より量: 100%のカバレッジはバグがないことを意味しない
  9. -raceフラグでテストを実行: 並列性の問題を早期に検出
  10. 高価なセットアップにはTestMainを使用: 各テストでセットアップを繰り返さない

例: 完全なテストスイート

package user

import (
    "errors"
    "testing"
)

type User struct {
    ID    int
    Name  string
    Email string
}

func ValidateUser(u *User) error {
    if u.Name == "" {
        return errors.New("名前は空にできません")
    }
    if u.Email == "" {
        return errors.New("メールアドレスは空にできません")
    }
    return nil
}

// テストファイル: user_test.go
func TestValidateUser(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
        errMsg  string
    }{
        {
            name:    "有効なユーザー",
            user:    &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
            wantErr: false,
        },
        {
            name:    "名前が空",
            user:    &User{ID: 1, Name: "", Email: "alice@example.com"},
            wantErr: true,
            errMsg:  "名前は空にできません",
        },
        {
            name:    "メールアドレスが空",
            user:    &User{ID: 1, Name: "Alice", Email: ""},
            wantErr: true,
            errMsg:  "メールアドレスは空にできません",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateUser(tt.user)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.want错误)
                return
            }
            
            if err != nil && err.Error() != tt.errMsg {
                t.Errorf("ValidateUser() error message = %v, want %v", err.Error(), tt.errMsg)
            }
        })
    }
}

有用なリンク

結論

Goのテストフレームワークは、最小限のセットアップで包括的なユニットテストを行うためのすべての機能を提供しています。テーブル駆動テストなどのGoの慣用法を採用し、インターフェースを使用してモックを作成し、組み込みツールを活用することで、コードベースとともに成長する保守性の高い、信頼性の高いテストスイートを作成できます。

これらのテストの実践は、ウェブサービスからCobra & Viperを使用したCLIアプリケーションまで、すべての種類のGoアプリケーションに適用されます。コマンドラインツールのテストには、入出力やフラグパーサーのテストに加えて、同様のパターンが必要です。

シンプルなテストから始め、カバレッジを段階的に追加し、テストはコード品質と開発者信頼性への投資であることを思い出してください。Goコミュニティがテストに重きを置いているため、長期的なプロジェクトの維持やチームとの効果的な協力が容易になります。