Go Webアプリケーションの堅牢なテスト戦略:単体テストからDocker統合まで
Olivia Novak
Dev Intern · Leapcell

Go で堅牢かつ保守性の高い Web アプリケーションを作成するには、しっかりとしたテスト戦略が不可欠です。今日のペースの速い開発環境では、アプリケーションは数多くの外部サービスやデータベースと統合されることが多いため、明確に定義されたテストピラミッドは単なるベストプラクティスではなく、必要不可欠なものです。これにより、コードの変更がリグレッションを引き起こさないこと、新機能が期待どおりに機能すること、そしてアプリケーションがあらゆる条件下で確実に動作することが保証されます。この記事では、Go Web アプリケーション向けの包括的なテストアプローチについて、単体テストの細かいレベルから始めて、Docker の力を活用したフルスタック統合テストへと段階的に進んでいきます。
テストの状況を理解する
実装に飛び込む前に、信頼性の高い Go アプリケーションを構築するために不可欠なコアテスト用語を簡単に定義しましょう。
- 単体テスト: これらは最も小さく、最も速いテストであり、通常は個々の関数やメソッドである、コードの分離された部分に焦点を当てます。その目標は、各コードユニットが外部依存関係から独立して、意図したタスクを正しく実行することを検証することです。
- 統合テスト: これらのテストは、アプリケーションのさまざまなコンポーネントまたはモジュール間の相互作用を検証することを目的としています。これには、サービスとデータベース、外部 API、またはその他の内部マイクロサービスとの通信のテストが含まれる場合があります。統合テストは、より多くのセットアップと外部リソースを必要とするため、通常は単体テストよりも遅くなります。
- エンドツーエンド (E2E) テスト: これらのテストは、アプリケーション全体を最初から最後まで、実際のユーザーのジャーニーをシミュレートします。UI、バックエンドロジック、および統合されたすべてのサービスを含むシステム全体を検証し、アプリケーションがビジネス要件を満たしていることを確認します。これらは重要ですが、維持するのが最も遅く、最も複雑です。
- モック: テストでは、モックオブジェクトは実際の依存関係の動作を模倣するシミュレートされたオブジェクトです。モックは、テスト対象のコードを外部依存関係から分離するために単体テストで一般的に使用され、依存関係の動作を制御し、遅いまたは予測不可能な外部呼び出しを回避できるようにします。
- テストダブル: 実際のオブジェクトの代わりに使用されるあらゆる種類のオブジェクトに対する一般的な用語です。モックは、スタブ、フェイク、スパイとともに、テストダブルの特定の種類です。
- テストフィクスチャ: テストの実行のためのベースラインとして使用される固定状態または環境。これには、データベースのセットアップ、外部サービスの構成、または初期データの入力が含まれる場合があります。
Go Web アプリケーションの単体テスト
Go は、単体テストを作成するための強力な組み込みツールを提供します。testing
パッケージがその中心であり、シンプルでありながら効果的なフレームワークを提供します。
典型的な Go Web アプリケーションには、ハンドラー、サービス、リポジトリが含まれることがよくあります。ユーザーサービスとやり取りするシンプルな HTTP ハンドラーを考えてみましょう。
// user_service.go package main import "errors" type User struct { ID string Name string } // UserService defines the interface for user-related operations type UserService interface { GetUserByID(id string) (*User, error) CreateUser(name string) (*User, error) } // MockUserService for testing type MockUserService struct { GetUserByIDFunc func(id string) (*User, error) CreateUserFunc func(name string) (*User, error) } func (m *MockUserService) GetUserByID(id string) (*User, error) { if m.GetUserByIDFunc != nil { return m.GetUserByIDFunc(id) } return nil, errors.New("not implemented") } func (m *MockUserService) CreateUser(name string) (*User, error) { if m.CreateUserFunc != nil { return m.CreateUserFunc(name) } return nil, errors.New("not implemented") }
次に、このサービスを使用する HTTP ハンドラーを記述しましょう。
// handlers.go package main import ( "encoding/json" "fmt" "net/http" ) type UserHandler struct { Service UserService } func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) { userID := r.URL.Query().Get("id") if userID == "" { http.Error(w, "User ID is required", http.StatusBadRequest) return } user, err := h.Service.GetUserByID(userID) if err != nil { http.Error(w, "User not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { var user User err := json.NewDecoder(r.Body).Decode(&user) if err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } createdUser, err := h.Service.CreateUser(user.Name) if err != nil { http.Error(w, "Failed to create user", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(createdUser) }
UserHandler
の単体テスト
UserHandler
をテストするには、UserService
の依存関係をモックする必要があります。これにより、ハンドラーのテストが実際のデータベースや外部サービスに依存しないようになります。
// handlers_test.go package main import ( "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" ) func TestUserHandler_GetUser(t *testing.T) { tests := []struct { name string userID string mockService *MockUserService expectedCode int expectedBody string }{ { name: "Successful Get", userID: "123", mockService: &MockUserService{ GetUserByIDFunc: func(id string) (*User, error) { if id == "123" { return &User{ID: "123", Name: "Alice"}, nil } return nil, errors.New("user not found") }, }, expectedCode: http.StatusOK, expectedBody: `{"ID":"123","Name":"Alice"}`, }, { name: "User Not Found", userID: "456", mockService: &MockUserService{ GetUserByIDFunc: func(id string) (*User, error) { return nil, errors.New("user not found") }, }, expectedCode: http.StatusNotFound, expectedBody: `User not found` + "\n", // http.Error adds a newline }, { name: "Missing User ID", userID: "", mockService: &MockUserService{}, // Mock service not called expectedCode: http.StatusBadRequest, expectedBody: `User ID is required` + "\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := &UserHandler{Service: tt.mockService} req, err := http.NewRequest("GET", "/users?id="+tt.userID, nil) if err != nil { t.Fatal(err) } recorder := httptest.NewRecorder() handler.GetUser(recorder, req) if recorder.Code != tt.expectedCode { t.Errorf("expected status code %d, got %d", tt.expectedCode, recorder.Code) } if reader := bytes.NewReader([]byte(tt.expectedBody)); reader.Len() > 0 { if recorder.Body.String() != tt.expectedBody { t.Errorf("expected body %q, got %q", tt.expectedBody, recorder.Body.String()) } } }) } } func TestUserHandler_CreateUser(t *testing.T) { tests := []struct { name string requestBody string mockService *MockUserService expectedCode int expectedBody string }{ { name: "Successful Create", requestBody: `{"Name":"Bob"}`, mockService: &MockUserService{ CreateUserFunc: func(name string) (*User, error) { return &User{ID: "new-id", Name: name}, nil }, }, expectedCode: http.StatusCreated, expectedBody: `{"ID":"new-id","Name":"Bob"}`, }, { name: "Invalid Request Body", requestBody: `{Invalid JSON}`, mockService: &MockUserService{}, // Mock service not called expectedCode: http.StatusBadRequest, expectedBody: `Invalid request body` + "\n", }, { name: "Service Failure", requestBody: `{"Name":"Charlie"}`, mockService: &MockUserService{ CreateUserFunc: func(name string) (*User, error) { return nil, errors.New("database error") }, }, expectedCode: http.StatusInternalServerError, expectedBody: `Failed to create user` + "\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := &UserHandler{Service: tt.mockService} req, err := http.NewRequest("POST", "/users", bytes.NewBufferString(tt.requestBody)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() handler.CreateUser(recorder, req) if recorder.Code != tt.expectedCode { t.Errorf("expected status code %d, got %d", tt.expectedCode, recorder.Code) } else { if reader := bytes.NewReader([]byte(tt.expectedBody)); reader.Len() > 0 { actualBody := bytes.TrimSpace(recorder.Body.Bytes()) // Remove potential trailing newline from http.Error expectedBody := bytes.TrimSpace([]byte(tt.expectedBody)) if !bytes.Equal(actualBody, expectedBody) { t.Errorf("expected body %q, got %q", string(expectedBody), string(actualBody)) } } } }) } }
この例は以下を示しています。
httptest.NewRecorder
とhttp.NewRequest
を使用して、実際の HTTP サーバーを起動せずに HTTP リクエストをシミュレートし、レスポンスをキャプチャします。MockUserService
を実装して、UserService
依存関係の動作を制御し、UserHandler
を分離してテストできるようにします。- クリーンで包括的なテストスイートのためのテーブル駆動テスト (
t.Run
)。
Docker を使用した統合テスト
単体テストは重要ですが、全体像をカバーしているわけではありません。実際のアプリケーションは、データベース、メッセージキュー、外部 API とやり取りします。統合テストは、これらの相互作用をテストすることで、このギャップを埋めます。Docker を使用すると、これらの外部依存関係のセットアップとテアダウンが大幅に簡素化され、統合テストが信頼性が高く再現可能になります。
UserService
の実装が PostgreSQL データベースを使用していると仮定します。
// real_user_service.go package main import ( "database/sql" "fmt" "log" _ "github.com/lib/pq" // PostgreSQL driver ) // DBUserService implements UserService using a PostgreSQL database type DBUserService struct { DB *sql.DB } func NewDBUserService(dataSourceName string) (*DBUserService, error) { db, err := sql.Open("postgres", dataSourceName) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } if err = db.Ping(); err != nil { return nil, fmt.Errorf("failed to connect to database: %w", err) } log.Println("Successfully connected to PostgreSQL") return &DBUserService{DB: db}, nil } func (s *DBUserService) GetUserByID(id string) (*User, error) { row := s.DB.QueryRow("SELECT id, name FROM users WHERE id = $1", id) user := &User{} err := row.Scan(&user.ID, &user.Name) if err == sql.ErrNoRows { return nil, errors.New("user not found") } if err != nil { return nil, fmt.Errorf("failed to get user: %w", err) } return user, nil } func (s *DBUserService) CreateUser(name string) (*User, error) { user := &User{Name: name} err := s.DB.QueryRow("INSERT INTO users (name) VALUES ($1) RETURNING id", name).Scan(&user.ID) if err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } return user, nil } // InitSchema creates the users table if it doesn't exist func (s *DBUserService) InitSchema() error { const schema = ` CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL ); ` _, err := s.DB.Exec(schema) return err }
テスト依存関係のための Docker Compose
統合テストでは、専用の PostgreSQL インスタンスを起動するために Docker Compose を使用します。docker-compose.test.yml
ファイルを作成します。
# docker-compose.test.yml version: '3.8' services: db_test: image: postgres:13 environment: POSTGRES_DB: test_db POSTGRES_USER: test_user POSTGRES_PASSWORD: test_password ports: - "5433:5432" # Map to a different port to avoid conflicts with local dev DB volumes: - pg_data_test:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U test_user -d test_db"] interval: 5s timeout: 5s retries: 5 volumes: pg_data_test:
統合テストの作成
次に、Docker 化された PostgreSQL に接続する実際の DBUserService
を使用する UserHandler
の統合テストを作成しましょう。
サービスを管理するためのヘルパー関数が必要です。
// integration_test_utils.go package main import ( "fmt" "os/exec" "time" ) // StartTestContainers brings up the Docker Compose services func StartTestContainers() error { cmd := exec.Command("docker-compose", "-f", "docker-compose.test.yml", "up", "-d") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to start containers: %s, %w", string(output), err) } fmt.Println("Docker containers started.") // Wait for the database to be healthy healthCheckCmd := exec.Command("docker-compose", "-f", "docker-compose.test.yml", "ps", "-q", "db_test") containerIDBytes, err := healthCheckCmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to get db_test container ID: %s, %w", string(containerIDBytes), err) } containerID := string(containerIDBytes) fmt.Println("Waiting for db_test to be healthy...") for i := 0; i < 60; i++ { // Wait up to 5 minutes (60 * 5s interval) healthCmd := exec.Command("docker", "inspect", "-f", "{{.State.Health.Status}}", containerID) healthOutput, err := healthCmd.CombinedOutput() if err == nil && string(healthOutput) == "healthy\n" { fmt.Println("db_test is healthy.") return nil } time.Sleep(5 * time.Second) } return errors.New("db_test health check failed after timeout") } // StopTestContainers brings down the Docker Compose services func StopTestContainers() error { cmd := exec.Command("docker-compose", "-f", "docker-compose.test.yml", "down", "-v") // -v removes volumes output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to stop containers: %s, %w", string(output), err) } fmt.Println("Docker containers stopped.") return nil }
そして、統合テスト自体:
// handlers_integration_test.go package main import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "strings" "testing" "time" ) const ( testDSN = "host=localhost port=5433 user=test_user password=test_password dbname=test_db sslmode=disable" ) func TestMain(m *testing.M) { if err := StartTestContainers(); err != nil { fmt.Printf("Failed to start test containers: %v\n", err) os.Exit(1) } // Run tests code := m.Run() if err := StopTestContainers(); err != nil { fmt.Printf("Failed to stop test containers: %v\n", err) // Don't exit here, as tests might have passed. Just log the error. } os.Exit(code) } func TestUserHandler_Integration(t *testing.T) { // Initialize real DBUserService userService, err := NewDBUserService(testDSN) if err != nil { t.Fatalf("Failed to initialize DBUserService: %v", err) } defer userService.DB.Close() // Ensure schema is clean for each test function run if _, err := userService.DB.Exec("DROP TABLE IF EXISTS users;"); err != nil { t.Fatalf("Failed to drop existing users table: %v", err) } if err := userService.InitSchema(); err != nil { t.Fatalf("Failed to initialize schema: %v", err) } handler := &UserHandler{Service: userService} t.Run("Create a user successfully", func(t *testing.T) { requestBody := `{"Name":"Integration Test User"}` req, err := http.NewRequest("POST", "/users", bytes.NewBufferString(requestBody)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() handler.CreateUser(recorder, req) if recorder.Code != http.StatusCreated { t.Errorf("expected status code %d, got %d. Body: %q", http.StatusCreated, recorder.Code, recorder.Body.String()) } var createdUser User err = json.Unmarshal(recorder.Body.Bytes(), &createdUser) if err != nil { t.Fatalf("Failed to unmarshal response: %v", err) } if createdUser.ID == "" { t.Error("Expected a user ID, got empty") } if createdUser.Name != "Integration Test User" { t.Errorf("Expected name 'Integration Test User', got '%s'", createdUser.Name) } }) t.Run("Get a created user successfully", func(t *testing.T) { // First, create a user directly via the service or a previous integration step testUser := &User{Name: "Another Integration User"} err := userService.DB.QueryRow("INSERT INTO users (name) VALUES ($1) RETURNING id", testUser.Name).Scan(&testUser.ID) if err != nil { t.Fatalf("Failed to pre-create user for GET test: %v", err) } req, err := http.NewRequest("GET", "/users?id="+testUser.ID, nil) if err != nil { t.Fatal(err) } recorder := httptest.NewRecorder() handler.GetUser(recorder, req) if recorder.Code != http.StatusOK { t.Errorf("expected status code %d, got %d. Body: %q", http.StatusOK, recorder.Code, recorder.Body.String()) } var fetchedUser User err = json.Unmarshal(recorder.Body.Bytes(), &fetchedUser) if err != nil { t.Fatalf("Failed to unmarshal response: %v", err) } if fetchedUser.ID != testUser.ID { t.Errorf("Expected user ID %s, got %s", testUser.ID, fetchedUser.ID) } if fetchedUser.Name != testUser.Name { t.Errorf("Expected name %s, got %s", testUser.Name, fetchedUser.Name) } }) t.Run("Get a non-existent user", func(t *testing.T) { req, err := http.NewRequest("GET", "/users?id=non-existent", nil) if err != nil { t.Fatal(err) } recorder := httptest.NewRecorder() handler.GetUser(recorder, req) if recorder.Code != http.StatusNotFound { t.Errorf("expected status code %d, got %d", http.StatusNotFound, recorder.Code) } expectedBody := "User not found\n" if recorder.Body.String() != expectedBody { t.Errorf("expected body %q, got %q", expectedBody, recorder.Body.String()) } }) }
これらの統合テストを実行するには:
- Docker が実行されていることを確認します。
- プロジェクトディレクトリに移動します。
go test -v -run Integration ./...
を実行します (または、具体的にはgo test -v -run Integration handlers_integration_test.go
)。
説明:
handlers_integration_test.go
のTestMain
関数は特別です。パッケージ内のどのテスト関数よりも前に実行されます。Docker Compose サービスを開始および停止するオーケストレーションに使用します。StartTestContainers
はdocker-compose up -d
を使用してサービスをバックグラウンドで起動し、データベースの準備完了を待機するヘルスチェックループが含まれています。StopTestContainers
はdocker-compose down -v
を使用してサービスをシャットダウンし、関連するボリュームを削除し、後続のテスト実行のためにクリーンな状態を保証します。TestUserHandler_Integration
の内部で、Docker 化された PostgreSQL に接続された実際のDBUserService
を初期化します。- 特に、各テスト実行またはテストケースでは、データベーススキーマがリセットされていることを確認します (
DROP TABLE IF EXISTS users;
およびInitSchema()
)。これにより、テストの分離が提供され、あるテストが他のテストに影響を与えるのを防ぎます。 httptest.NewRecorder
とhttp.NewRequest
を再度使用しますが、今回はUserHandler
がライブデータベースとやり取りし、モックではありません。
結論
適切に構造化されたテスト戦略は、信頼性の高い Go Web アプリケーションを開発するために不可欠です。Go の標準ライブラリを使用した単体テストを習得し、依存関係の分離のためにモックを慎重に採用することにより、高速で安定した基盤を構築します。Docker を活用した統合テストでこれを拡張することで、実際の外部サービスとのやり取りを含むフルスタックを検証し、アプリケーションが本番環境のような環境で正しく動作することを保証します。単体からシステム全体までのテストに対するこのレイヤー化されたアプローチは、最終的に、より堅牢で保守性が高く、信頼できる Web アプリケーションにつながります。