テスト

レッスン23:テスト

生活での例え

あなたがレストランを開いたと想像してください。すべての料理が提供される前に、シェフがまず味見をします — 味付けが正しいか、調理が適切かを確認するためです。ソフトウェアテストはこの「味見」プロセスのようなものです。コードがユーザーに届けられる前に、期待通りに動作するかが自動的に検証されます。テストなしで公開することは、味見なしで料理を出すようなもので、問題が発生するのは時間の問題です。

コアコンセプト

Goには完全なテストツールチェーンが組み込まれており、サードパーティのフレームワークは不要です。コアコンセプトは以下の通りです:

コンセプト 説明
testing.T 単体テストコンテキストオブジェクト。テスト失敗の報告に使用
testing.B ベンチマークテストコンテキストオブジェクト。パフォーマンスの測定に使用
testing.M テストエントリーオブジェクト。TestMainのグローバルセットアップに使用
*_test.go テストファイルの命名規則。テスト時のみコンパイルされる
go test テストを実行するコマンドラインツール
テーブル駆動テスト データテーブルで複数のテストケースを駆動するGo流のパターン
httptest HTTPリクエストのテストヘルパーパッケージ

基本構文と使い方

テストファイルの規則

テストファイルは_test.goで終わる名前で、テスト対象のコードと同じパッケージに配置する必要があります:

myapp/
├── math.go
└── math_test.go

テスト関数のシグネチャ

GO
func TestXxx(t *testing.T) {
    // テストロジック
}

関数名はTestで始まる必要があり、パラメータは*testing.Tです。

アサーションメソッド

Goには組み込みのassert関数がないため、手動でチェックする必要があります:

GO
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d, 期待値 5", result)
    }
}

一般的なメソッド:

メソッド 用途
t.Error() / t.Errorf() エラーを報告し、実行を継続
t.Fatal() / t.Fatalf() エラーを報告し、現在のテストを即座に終了
t.Skip() / t.Skipf() 現在のテストをスキップ
t.Log() / t.Logf() ログを出力(-v時のみ表示)
t.Run(name, func) サブテストを実行
t.Helper() ヘルパー関数としてマークし、エラー報告を呼び出し元に向ける
💡 ヒント1: テスト関数のパラメータと戻り値は、検証しやすいようにできるだけシンプルにしてください。複雑な入力にはテーブル駆動テストを使用してください。

💡 ヒント2: t.Helper()でアサーションヘルパー関数をマークすると、失敗時にヘルパー関数内部ではなく呼び出し元の位置が報告されます。

💡 ヒント3: go test -vで詳細出力、go test -run=regexでマッチするテストのみ実行、-count=1でキャッシュを無効化できます。

テストの実行

BASH
# 現在のパッケージのすべてのテストを実行
go test

# 詳細出力
go test -v

# 名前にマッチするテストのみ実行
go test -run TestAdd

# 現在のディレクトリとサブディレクトリで実行
go test ./...

# カバレッジを表示
go test -cover

例題

例:基本的な単体テスト(難易度⭐)

ファイル構造:

calculator/
├── calc.go
└── calc_test.go
▶ 試してみよう

calc.go:

GO
package calculator

// Add 2つの数値の合計を返します
func Add(a, b int) int {
    return a + b
}

// Divide 商とエラーがあるかどうかを返します
func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("除数はゼロにできません")
    }
    return a / b, nil
}

calc_test.go:

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, 期待値 %d", got, want)
    }
}

func TestDivide(t *testing.T) {
    got, err := Divide(10, 3)
    if err != nil {
        t.Fatalf("予期しないエラー: %v", err)
    }
    want := 3.3333333333333335
    if got != want {
        t.Errorf("Divide(10, 3) = %f, 期待値 %f", got, want)
    }
}

func TestDivideByZero(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Error("ゼロ除算はエラーを返すべきです")
    }
}

出力:

BASH
$ go test -v
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN   TestDivide
--- PASS: TestDivide (0.00s)
=== RUN   TestDivideByZero
--- PASS: TestDivideByZero (0.00s)
PASS
ok      calculator      0.003s

例:テーブル駆動テストとサブテスト(難易度⭐⭐)

テーブル駆動テストはGoコミュニティで最も評価されているテストパターンです。入力と期待出力をスライスに入れ、ループで検証します。

stringutil.go:

GO
package stringutil

import "unicode"

// Reverse 文字列を反転します
func Reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

// IsPalindrome 文字列が回文かどうかをチェックします
func IsPalindrome(s string) bool {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        if unicode.ToLower(runes[i]) != unicode.ToLower(runes[j]) {
            return false
        }
    }
    return true
}
▶ 試してみよう

stringutil_test.go:

GO
package stringutil

import "testing"

func TestReverse(t *testing.T) {
    // テストケーステーブルを定義
    tests := []struct {
        name  string // ケース名
        input string
        want  string
    }{
        {"空文字列", "", ""},
        {"単一文字", "a", "a"},
        {"通常の文字列", "hello", "olleh"},
        {"中国語", "你好世界", "界世好你"},
        {"回文", "racecar", "racecar"},
    }

    for _, tt := range tests {
        // t.Runはサブテストを作成し、各ケースは独立して実行される
        t.Run(tt.name, func(t *testing.T) {
            got := Reverse(tt.input)
            if got != tt.want {
                t.Errorf("Reverse(%q) = %q, 期待値 %q", tt.input, got, tt.want)
            }
        })
    }
}

func TestIsPalindrome(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  bool
    }{
        {"英語の回文", "racecar", true},
        {"中国語の回文", "上海自来水来自海上", true},
        {"回文ではない", "hello", false},
        {"大文字小文字を区別しない", "RaceCar", true},
        {"空文字列", "", true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := IsPalindrome(tt.input)
            if got != tt.want {
                t.Errorf("IsPalindrome(%q) = %v, 期待値 %v", tt.input, got, tt.want)
            }
        })
    }
}

特定のサブテストを実行:

BASH
# 中国語の回文ケースのみ実行
$ go test -v -run=TestIsPalindrome/中国語の回文
=== RUN   TestIsPalindrome/中国語の回文
--- PASS: TestIsPalindrome/中国語の回文 (0.00s)
PASS
💡 テーブル駆動テストの利点:新しいケースを追加するにはスライスに行を追加するだけで、新しいテスト関数を書く必要がありません。


例:ベンチマーク、TestMain、httptest(難易度⭐⭐⭐)

handler.go:

GO
package handler

import (
    "encoding/json"
    "net/http"
)

type Response struct {
    Message string `json:"message"`
    Code    int    `json:"code"`
}

// HelloHandler /helloリクエストを処理します
func HelloHandler(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    if name == "" {
        name = "World"
    }

    resp := Response{
        Message: "Hello, " + name,
        Code:    200,
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}
▶ 試してみよう

handler_test.go:

GO
package handler

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "os"
    "testing"
)

// TestMain すべてのテストの前に実行される。グローバルなセットアップとティアダウンに使用可能
func TestMain(m *testing.M) {
    // ここでデータベース接続のセットアップ、設定の初期化などが可能
    setup()

    // すべてのテストを実行
    code := m.Run()

    // リソースをクリーンアップ
    teardown()

    // テスト結果を終了コードとして使用
    os.Exit(code)
}

func setup() {
    // 初期化コード(例:テスト設定の読み込み)
}

func teardown() {
    // クリーンアップコード(例:データベース接続のクローズ)
}

func TestHelloHandler(t *testing.T) {
    tests := []struct {
        name       string
        queryParam string
        wantMsg    string
        wantCode   int
    }{
        {"デフォルトの名前", "", "Hello, World", 200},
        {"カスタム名前", "Go", "Hello, Go", 200},
        {"中国語の名前", "小明", "Hello, 小明", 200},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // リクエストを構築
            url := "/hello"
            if tt.queryParam != "" {
                url += "?name=" + tt.queryParam
            }
            req := httptest.NewRequest(http.MethodGet, url, nil)

            // レスポンスレコーダーを作成
            rr := httptest.NewRecorder()

            // ハンドラーを呼び出し
            HelloHandler(rr, req)

            // ステータスコードを検証
            if rr.Code != tt.wantCode {
                t.Errorf("ステータスコード = %d, 期待値 %d", rr.Code, tt.wantCode)
            }

            // レスポンスボディを検証
            var resp Response
            if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
                t.Fatalf("レスポンスの解析に失敗しました: %v", err)
            }
            if resp.Message != tt.wantMsg {
                t.Errorf("メッセージ = %q, 期待値 %q", resp.Message, tt.wantMsg)
            }
        })
    }
}

// BenchmarkHelloHandler ベンチマークテスト。ハンドラーのパフォーマンスを測定
func BenchmarkHelloHandler(b *testing.B) {
    req := httptest.NewRequest(http.MethodGet, "/hello?name=Go", nil)

    // b.Nはテストフレームワークによって自動調整され、安定した結果を保証
    for i := 0; i < b.N; i++ {
        rr := httptest.NewRecorder()
        HelloHandler(rr, req)
    }
}

// BenchmarkReverse 文字列反転のベンチマーク
func BenchmarkReverse(b *testing.B) {
    s := "Hello, World! This is a test string"
    for i := 0; i < b.N; i++ {
        _ = Reverse(s) // Reverseが同じパッケージにあると仮定
    }
}

ベンチマークの実行:

BASH
$ go test -bench=. -benchmem -count=3
goos: linux
goarch: amd64
pkg: handler
BenchmarkHelloHandler-8     200000    7523 ns/op    1248 B/op    18 allocs/op
BenchmarkHelloHandler-8     200000    7401 ns/op    1248 B/op    18 allocs/op
BenchmarkHelloHandler-8     200000    7350 ns/op    1248 B/op    18 allocs/op
PASS
ok      handler 4.832s

カバレッジの確認:

BASH
# カバレッジレポートを生成
$ go test -coverprofile=coverage.out

# ターミナルで関数ごとのカバレッジを表示
$ go tool cover -func=coverage.out
total:  (statements)    85.7%

# HTMLレポートを生成(ブラウザで開く)
$ go tool cover -html=coverage.out -o coverage.html

シナリオ応用

シナリオ1:ミドルウェアのテスト

Web開発では、認証やログなどのミドルウェアを独立してテストする必要があります:

GO
package middleware

import (
    "net/http"
    "strings"
)

// AuthMiddleware リクエストヘッダーのTokenをチェックします
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if !strings.HasPrefix(token, "Bearer ") {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

テスト:

GO
package middleware

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestAuthMiddleware(t *testing.T) {
    // シンプルなダウンストリームハンドラーを作成
    nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("Passed"))
    })

    middleware := AuthMiddleware(nextHandler)

    t.Run("トークンなしは401を返すべき", func(t *testing.T) {
        req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
        rr := httptest.NewRecorder()
        middleware.ServeHTTP(rr, req)

        if rr.Code != http.StatusUnauthorized {
            t.Errorf("ステータスコード = %d, 期待値 %d", rr.Code, http.StatusUnauthorized)
        }
    })

    t.Run("有効なトークンは通過すべき", func(t *testing.T) {
        req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
        req.Header.Set("Authorization", "Bearer my-secret-token")
        rr := httptest.NewRecorder()
        middleware.ServeHTTP(rr, req)

        if rr.Code != http.StatusOK {
            t.Errorf("ステータスコード = %d, 期待値 %d", rr.Code, http.StatusOK)
        }
    })

    t.Run("無効なプレフィックスは401を返すべき", func(t *testing.T) {
        req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
        req.Header.Set("Authorization", "Basic abc123")
        rr := httptest.NewRecorder()
        middleware.ServeHTTP(rr, req)

        if rr.Code != http.StatusUnauthorized {
            t.Errorf("ステータスコード = %d, 期待値 %d", rr.Code, http.StatusUnauthorized)
        }
    })
}

シナリオ2:データベース操作のテスト(インターフェース分離)

インターフェースを通じてデータベース依存を分離し、テスト時にモックを注入します:

GO
package user

import "context"

// UserRepository ユーザーデータアクセスインターフェースを定義
type UserRepository interface {
    GetByID(ctx context.Context, id int) (*User, error)
    Create(ctx context.Context, user *User) error
}

// User モデル
type User struct {
    ID   int
    Name string
}

// Service ユーザービジネスロジック
type Service struct {
    repo UserRepository
}

// NewService ユーザーサービスを作成
func NewService(repo UserRepository) *Service {
    return &Service{repo: repo}
}

// GetUser ユーザーを取得。見つからない場合はエラーを返す
func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
    if id <= 0 {
        return nil, fmt.Errorf("無効なユーザーID: %d", id)
    }
    return s.repo.GetByID(ctx, id)
}

モックとテスト:

GO
package user

import (
    "context"
    "testing"
)

// mockRepo テスト用のUserRepositoryインターフェース実装
type mockRepo struct {
    users map[int]*User
}

func (m *mockRepo) GetByID(_ context.Context, id int) (*User, error) {
    if u, ok := m.users[id]; ok {
        return u, nil
    }
    return nil, fmt.Errorf("ユーザー %d は存在しません", id)
}

func (m *mockRepo) Create(_ context.Context, user *User) error {
    m.users[user.ID] = user
    return nil
}

func TestGetUser(t *testing.T) {
    mock := &mockRepo{
        users: map[int]*User{
            1: {ID: 1, Name: "Alice"},
            2: {ID: 2, Name: "Bob"},
        },
    }
    svc := NewService(mock)

    t.Run("既存のユーザー", func(t *testing.T) {
        user, err := svc.GetUser(context.Background(), 1)
        if err != nil {
            t.Fatalf("予期しないエラー: %v", err)
        }
        if user.Name != "Alice" {
            t.Errorf("名前 = %q, 期待値 %q", user.Name, "Alice")
        }
    })

    t.Run("存在しないユーザー", func(t *testing.T) {
        _, err := svc.GetUser(context.Background(), 99)
        if err == nil {
            t.Error("エラーが返されることを期待しました")
        }
    })

    t.Run("無効なID", func(t *testing.T) {
        _, err := svc.GetUser(context.Background(), -1)
        if err == nil {
            t.Error("エラーが返されることを期待しました")
        }
    })
}

❓ よくある質問

質問1:テストファイルが認識されない場合はどうすればいいですか?

ファイル名が_test.goで終わり、パッケージ名が正しいことを確認してください。テストファイルはgo build時には無視され、go test時にのみコンパイルされます。テストファイルが_testパッケージ(つまりpackage foo_test)にある場合、エクスポートされた識別子のみをテストできます。

質問2:失敗したテストのみを実行するにはどうすればいいですか?

-rcパラメータと正規表現を使用します:

BASH
# 名前で特定のテストを実行
go test -run TestDivideByZero -v

# go test -vを使用している場合、失敗したテスト名が出力に表示されます

-count=1と組み合わせてキャッシュを無効化し、テストが実際に再実行されることも確認できます:

BASH
go test -run TestSomething -count=1 -v

質問3:t.Fatalt.Errorの違いは何ですか?

一般的な原則:後続のアサーションが前の結果に依存する場合(例:errをチェックしてからポインタをデリファレンスする場合)はFatalを使用し、独立した複数のアサーションにはErrorを使用します。

質問4:時間のかかるテストをスキップするにはどうすればいいですか?

GO
func TestSlowOperation(t *testing.T) {
    if testing.Short() {
        t.Skip("時間のかかるテストをスキップ(-shortフラグを使用)")
    }
    // 時間のかかる操作...
}

-shortフラグで実行してスキップ:

BASH
go test -short

📖 まとめ

このレッスンではGoのテストのコア内容をカバーしました:

  1. テスト関数Testで始まり、パラメータは*testing.TError/Fatalで失敗を報告
  2. テーブル駆動テスト:スライスでケースを定義、ループ + t.Runで実行 — Goコミュニティ流のパターン
  3. サブテストt.Run(name, func)でケースを独立実行、問題の特定が容易
  4. ベンチマークテストBenchmarkで始まり、パラメータは*testing.Bb.N回ループしてパフォーマンスを測定
  5. TestMain:セットアップ/ティアダウンのグローバルエントリーポイント、m.Run()で全テストを実行
  6. httptesthttptest.NewRequest + httptest.NewRecorderでHTTPハンドラーをテスト
  7. カバレッジgo test -coverで表示、-coverprofileで詳細レポートを生成

📝 演習

演習1:文字列処理関数のテストを書く

以下の関数のテストを書き、少なくとも5つのテーブル駆動ケースを含めてください:

GO
// Truncate 文字列を指定された長さに切り詰め、超過分を"..."で置換します
func Truncate(s string, maxLen int) string {
    runes := []rune(s)
    if len(runes) <= maxLen {
        return s
    }
    return string(runes[:maxLen-3]) + "..."
}

演習2:HTTPハンドラーのベンチマークを書く

JSON配列を返すAPIのベンチマークを書き、json.Marshaljson.Encoderのパフォーマンス差を比較してください:

GO
// ListHandler ユーザーリストを返します
func ListHandler(w http.ResponseWriter, r *http.Request) {
    users := []User{{1, "Alice"}, {2, "Bob"}, {3, "Charlie"}}
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(users)
}

要件:

演習3:TestMainでテストデータベースを実装する

TestMainを使用したテストソリューションを設計してください:

  1. TestMainで一時的なSQLiteデータベースを作成
  2. マイグレーションを実行してテーブルを作成
  3. すべてのテストを実行
  4. テスト完了後に一時データベースを削除

ヒント:m.Run()の戻り値をos.Exit()のパラメータとして使用します。


次のレッスン

次のレッスン:正規表現と日付 →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%