Chuyển đến nội dung chính

レッスン 18: Go でのテスト

単体テスト、テーブル駆動テスト。証人を嘲笑、嘲笑。テストコンテナを使用した統合テスト。 HTTP ハンドラーのテスト。ベンチマークテスト、ファジング。テストカバレッジ、CI統合。

💻 プログラミング — レッスン 18 レッスン 18: Go でのテスト

Golang: 基本から高度まで

パート 5: マイクロサービス、テスト、本番環境

xdev.asia

1. テストの基本

// math.go
package math

func Add(a, b int) int { return a + b }
func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}

// math_test.go
package math

import "testing"

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

func TestDivide(t *testing.T) {
    result, err := Divide(10, 2)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result != 5 {
        t.Errorf("Divide(10, 2) = %f; want 5", result)
    }
}

func TestDivideByZero(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Error("expected error for division by zero")
    }
}

2. テーブル駆動テスト

func TestAdd_TableDriven(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -2, -3, -5},
        {"mixed", -2, 3, 1},
        {"zeros", 0, 0, 0},
        {"large numbers", 1000000, 2000000, 3000000},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

func TestDivide_TableDriven(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        expected  float64
        expectErr bool
    }{
        {"normal", 10, 2, 5, false},
        {"division by zero", 10, 0, 0, true},
        {"negative", -10, 2, -5, false},
        {"decimal", 7, 3, 2.3333333333333335, false},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Divide(tt.a, tt.b)
            
            if tt.expectErr {
                if err == nil {
                    t.Error("expected error, got nil")
                }
                return
            }
            
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if result != tt.expected {
                t.Errorf("got %f; want %f", result, tt.expected)
            }
        })
    }
}

3. 証言する — 主張と嘲笑

go get github.com/stretchr/testify
import (
    "testing"
    
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestAdd_Testify(t *testing.T) {
    assert.Equal(t, 5, Add(2, 3))
    assert.NotEqual(t, 4, Add(2, 3))
}

func TestDivide_Testify(t *testing.T) {
    result, err := Divide(10, 2)
    require.NoError(t, err)              // Fail immediately if error
    assert.Equal(t, 5.0, result)
    assert.InDelta(t, 5.0, result, 0.01) // Float comparison
    
    _, err = Divide(10, 0)
    assert.Error(t, err)
    assert.Contains(t, err.Error(), "division by zero")
}

3.1.インターフェースモッキング

// Repository interface
type UserRepository interface {
    GetByID(ctx context.Context, id uint) (*User, error)
    Create(ctx context.Context, user *User) error
    GetByEmail(ctx context.Context, email string) (*User, error)
}

// Mock (auto-generate: go install github.com/vektra/mockery/v2@latest)
// mockery --name=UserRepository --output=mocks

// Manual mock
type MockUserRepository struct {
    mock.Mock
}

func (m *MockUserRepository) GetByID(ctx context.Context, id uint) (*User, error) {
    args := m.Called(ctx, id)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*User), args.Error(1)
}

func (m *MockUserRepository) Create(ctx context.Context, user *User) error {
    args := m.Called(ctx, user)
    return args.Error(0)
}

func (m *MockUserRepository) GetByEmail(ctx context.Context, email string) (*User, error) {
    args := m.Called(ctx, email)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*User), args.Error(1)
}

// Test with mock
func TestUserService_GetUser(t *testing.T) {
    mockRepo := new(MockUserRepository)
    svc := NewUserService(mockRepo)
    
    expectedUser := &User{ID: 1, Name: "Duy", Email: "[email protected]"}
    
    // Setup expectations
    mockRepo.On("GetByID", mock.Anything, uint(1)).Return(expectedUser, nil)
    mockRepo.On("GetByID", mock.Anything, uint(999)).Return(nil, fmt.Errorf("not found"))
    
    // Test success
    user, err := svc.GetUser(context.Background(), 1)
    require.NoError(t, err)
    assert.Equal(t, "Duy", user.Name)
    
    // Test not found
    _, err = svc.GetUser(context.Background(), 999)
    assert.Error(t, err)
    
    // Verify expectations
    mockRepo.AssertExpectations(t)
    mockRepo.AssertCalled(t, "GetByID", mock.Anything, uint(1))
}

4. HTTP ハンドラーのテスト

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    
    "github.com/gin-gonic/gin"
    "github.com/stretchr/testify/assert"
)

func setupRouter() *gin.Engine {
    gin.SetMode(gin.TestMode)
    r := gin.New()
    
    mockRepo := new(MockUserRepository)
    svc := NewUserService(mockRepo)
    handler := NewUserHandler(svc)
    
    mockRepo.On("GetByID", mock.Anything, uint(1)).Return(&User{
        ID: 1, Name: "Duy", Email: "[email protected]",
    }, nil)
    
    r.GET("/users/:id", handler.GetUser)
    r.POST("/users", handler.CreateUser)
    
    return r
}

func TestGetUser_Success(t *testing.T) {
    router := setupRouter()
    
    w := httptest.NewRecorder()
    req, _ := http.NewRequest("GET", "/users/1", nil)
    router.ServeHTTP(w, req)
    
    assert.Equal(t, http.StatusOK, w.Code)
    
    var response map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &response)
    
    data := response["data"].(map[string]interface{})
    assert.Equal(t, "Duy", data["name"])
}

func TestCreateUser_Validation(t *testing.T) {
    router := setupRouter()
    
    tests := []struct {
        name       string
        body       map[string]interface{}
        wantStatus int
    }{
        {
            name:       "missing name",
            body:       map[string]interface{}{"email": "[email protected]"},
            wantStatus: http.StatusBadRequest,
        },
        {
            name:       "invalid email",
            body:       map[string]interface{}{"name": "Test", "email": "invalid"},
            wantStatus: http.StatusBadRequest,
        },
        {
            name:       "valid input",
            body:       map[string]interface{}{"name": "Test", "email": "[email protected]", "password": "12345678"},
            wantStatus: http.StatusCreated,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            body, _ := json.Marshal(tt.body)
            w := httptest.NewRecorder()
            req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body))
            req.Header.Set("Content-Type", "application/json")
            
            router.ServeHTTP(w, req)
            assert.Equal(t, tt.wantStatus, w.Code)
        })
    }
}

5. Testcontainer を使用した統合テスト

go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
import (
    "context"
    "testing"
    
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
)

func setupPostgres(t *testing.T) (string, func()) {
    ctx := context.Background()
    
    pgContainer, err := postgres.Run(ctx,
        "postgres:16-alpine",
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2),
        ),
    )
    require.NoError(t, err)
    
    connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
    require.NoError(t, err)
    
    cleanup := func() {
        pgContainer.Terminate(ctx)
    }
    
    return connStr, cleanup
}

func TestUserRepository_Integration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test")
    }
    
    connStr, cleanup := setupPostgres(t)
    defer cleanup()
    
    // Setup GORM with test database
    db, err := gorm.Open(postgres.Open(connStr), &gorm.Config{})
    require.NoError(t, err)
    
    db.AutoMigrate(&User{})
    
    repo := NewUserRepository(db)
    ctx := context.Background()
    
    // Test Create
    user := &User{Name: "Test User", Email: "[email protected]"}
    err = repo.Create(ctx, user)
    assert.NoError(t, err)
    assert.NotZero(t, user.ID)
    
    // Test GetByID
    found, err := repo.GetByID(ctx, user.ID)
    assert.NoError(t, err)
    assert.Equal(t, "Test User", found.Name)
    
    // Test GetByEmail
    found, err = repo.GetByEmail(ctx, "[email protected]")
    assert.NoError(t, err)
    assert.Equal(t, user.ID, found.ID)
}

6. ファズテスト

func FuzzDivide(f *testing.F) {
    // Seed corpus
    f.Add(10.0, 2.0)
    f.Add(0.0, 1.0)
    f.Add(-5.0, 3.0)
    
    f.Fuzz(func(t *testing.T, a, b float64) {
        result, err := Divide(a, b)
        
        if b == 0 {
            if err == nil {
                t.Error("expected error for division by zero")
            }
            return
        }
        
        if err != nil {
            t.Errorf("unexpected error: %v", err)
        }
        
        // Verify: a / b * b ≈ a
        if !math.IsNaN(result) && !math.IsInf(result, 0) {
            if diff := math.Abs(result*b - a); diff > 1e-9 {
                t.Errorf("Divide(%f, %f) = %f; verification failed", a, b, result)
            }
        }
    })
}
# Run fuzz tests
go test -fuzz=FuzzDivide -fuzztime=30s ./...

7. テストコマンド

# Run all tests
go test ./...

# Run with verbose output
go test -v ./...

# Run specific test
go test -run TestAdd ./math/

# Run with race detector
go test -race ./...

# Skip long tests
go test -short ./...

# Coverage
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
go tool cover -func=coverage.out

# Benchmark
go test -bench=. -benchmem ./...

# Fuzz
go test -fuzz=FuzzDivide -fuzztime=30s ./...

次の記事: Docker、CI/CD、DevOps — コンテナ化、パイプライン、デプロイメントの自動化。