GoテストフレームワークTestify実践:2026年プロダクション級ユニットテストパターンとベストプラクティス

编程语言

if err != nilがreflect.DeepEqualと出会う時:Goテストの苦痛の連鎖

こんなテストを書いたことはありませんか?

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

シンプルな加算テストなら問題ありません。しかし、スライスの等価性、マップの比較、構造体の深い比較、エラータイプのマッチングをテストしようとすると、コードは悪夢に変わります——reflect.DeepEqualが至る所に現れ、エラーメッセージは読みにくく、テストが失敗しても"not equal"としか表示されず、どこが違うのか分かりません。Goの標準ライブラリにはMockフレームワークが組み込まれておらず、インターフェースのモックは手書き、テストの構成はifの積み重ねに頼るしかありません。

これは決して稀なケースではありません。楽天、LINEヤフーなどの大企業のGoプロジェクトにおいて、testifyなしのテストコードはビジネスコードより長くなることがよくあります。本記事では5つのプロダクション級テストパターンを解説し、testifyフレームワークを徹底的にマスターし、Goテストを「動けばいい」から「プロフェッショナルで信頼性の高い」レベルへ進化させます。


コア概念リファレンス

コンポーネント 用途 主な特徴 典型的なユースケース
assert アサーションと失敗の記録 失敗後も実行を継続、全エラーを収集 複数アサーションのあるユニットテスト
require アサーションと即時停止 失敗時に現在のテストを停止、nilポインタのカスケードを回避 前提条件のチェック
suite テストスイートの構成 Setup/TearDownライフサイクル、状態の共有 統合テスト、データベーステスト
mock インターフェースのシミュレーション メソッド呼び出しアサーションの自動生成 依存関係の分離、外部サービスのシミュレーション
httptest HTTPテストサーバー 実際のHTTPリクエストシミュレーション、サーバー起動不要 APIハンドラーテスト

Goテストの5つの課題

課題1:冗長なアサーションと不親切なメッセージ

func TestUserEqual(t *testing.T) {
    got := GetUser(1)
    want := User{ID: 1, Name: "田中", Age: 30}
    if got.ID != want.ID {
        t.Errorf("ID: got %d, want %d", got.ID, want.ID)
    }
    if got.Name != want.Name {
        t.Errorf("Name: got %s, want %s", got.Name, want.Name)
    }
    if got.Age != want.Age {
        t.Errorf("Age: got %d, want %d", got.Age, want.Age)
    }
}

3つのフィールドで3つのifブロックが必要です。10個のフィールドなら?ネストされた構造体なら?reflect.DeepEqualは比較できますが、失敗メッセージはnot equalだけで、どのフィールドが違うのか全く分かりません。

課題2:組み込みMockがなく、手書きモックコードが爆発

type UserRepository interface {
    GetByID(ctx context.Context, id int64) (*User, error)
    Create(ctx context.Context, user *User) error
    Update(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int64) error
    List(ctx context.Context, filter Filter) ([]*User, error)
}

5つのメソッドを持つインターフェースでは、テストで1つしか使わなくても5つのメソッドを実装する必要があります。10個のインターフェースなら50個のメソッド実装——モックコードがビジネスコードを超えます。

課題3:テスト構成が乱雑、Setup/TearDownの重複

すべてのテストでdb.Connect()が必要、テスト終了ごとにdb.Close()が必要、テストデータのクリーンアップも必要。コピペが至る所に、TearDownの漏れが次のテストを汚染します。

課題4:テーブル駆動テストのアサーションジレンマ

テーブル駆動テストはGoの慣用的パターンですが、標準ライブラリのアサーションはテーブル駆動テストで不十分——t.Errorfはどのテストケースが失敗したかを教えてくれず、手動でケース名を追加して区別するしかありません。

課題5:HTTPテストが実際のサービスに依存

APIハンドラーのテスト——HTTPサーバーを起動する?httptest.NewRecorderを使う?リクエストボディの構築方法は?レスポンスのアサーション方法は?統一されたパターンがなく、チーム内で10人が10通りのスタイルを書きます。


パターン1:assertとrequire——アサーションの二つの哲学

assert vs require の核心的な違い

package user_test

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

func TestAssertVsRequire(t *testing.T) {
    // assert: 失敗後も実行を継続、全エラーを収集
    assert.Equal(t, 1, 2, "最初のアサーション")
    assert.Equal(t, 3, 3, "2番目のアサーション") // まだ実行される
    assert.NotNil(t, nil, "3番目のアサーション")    // まだ実行される
    // テスト結果:3つの失敗、すべての問題を一度に確認
}

func TestRequireBehavior(t *testing.T) {
    // require: 失敗時に現在のテストを即座に停止
    require.Equal(t, 1, 2, "前提条件を満たさない")
    // この行は決して実行されない
    require.Equal(t, 3, 3, "ここには到達しない")
}

プロダクション級アサーション実践

package service_test

import (
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "myapp/model"
    "myapp/service"
)

func TestCreateUser(t *testing.T) {
    // requireは前提条件に使用:作成が成功しなければ、後続のアサーションは無意味
    user, err := service.CreateUser("tanaka", "tanaka@example.com")
    require.NoError(t, err, "ユーザー作成でエラーが返されるべきではない")
    require.NotNil(t, user, "ユーザー作成でnilが返されるべきではない")

    // assertはビジネスアサーションに使用:あるフィールドが間違っていても、他のフィールドの結果を見たい
    assert.Equal(t, "tanaka", user.Name, "ユーザー名が一致するべき")
    assert.Equal(t, "tanaka@example.com", user.Email, "メールアドレスが一致するべき")
    assert.NotZero(t, user.ID, "ユーザーIDはゼロ値であるべきではない")
    assert.WithinDuration(t, time.Now(), user.CreatedAt, time.Second, "作成時間は現在時刻に近いべき")
}

よく使うアサーションメソッドリファレンス

// 等価性
assert.Equal(t, expected, actual)
assert.NotEqual(t, expected, actual)
assert.Exactly(t, expected, actual) // 型+値が完全に一致

// ブール判定
assert.True(t, condition)
assert.False(t, condition)

// nilチェック
assert.Nil(t, obj)
assert.NotNil(t, obj)

// エラーチェック
assert.NoError(t, err)
assert.Error(t, err)
assert.ErrorIs(t, err, os.ErrNotExist)
assert.ErrorContains(t, err, "not found")

// コレクション
assert.Contains(t, slice, element)
assert.NotContains(t, slice, element)
assert.ElementsMatch(t, []int{1,2,3}, []int{3,1,2}) // 順序なしマッチ
assert.Subset(t, []int{1,2,3,4}, []int{2,3})

// 型チェック
assert.IsType(t, &User{}, actual)
assert.Implements(t, (*Repository)(nil), actual)

// 数値比較
assert.Greater(t, 10, 5)
assert.LessOrEqual(t, 5, 5)
assert.InDelta(t, 3.14, 3.14159, 0.01)

// 文字列
assert.Empty(t, "")
assert.Regexp(t, `^\d+$`, "12345")

パターン2:テーブル駆動テスト + testify suite

基本的なテーブル駆動テスト

package calculator_test

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "myapp/calculator"
)

func TestDivide(t *testing.T) {
    tests := []struct {
        name        string
        a           float64
        b           float64
        want        float64
        wantErr     bool
        errContains string
    }{
        {
            name:    "通常の除算",
            a:       10.0,
            b:       2.0,
            want:    5.0,
            wantErr: false,
        },
        {
            name:        "ゼロ除算",
            a:           10.0,
            b:           0.0,
            wantErr:     true,
            errContains: "division by zero",
        },
        {
            name:    "負数の除算",
            a:       -10.0,
            b:       2.0,
            want:    -5.0,
            wantErr: false,
        },
        {
            name:    "浮動小数点精度",
            a:       1.0,
            b:       3.0,
            want:    0.333333,
            wantErr: false,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := calculator.Divide(tt.a, tt.b)
            if tt.wantErr {
                assert.Error(t, err)
                assert.Contains(t, err.Error(), tt.errContains)
                return
            }
            assert.NoError(t, err)
            assert.InDelta(t, tt.want, got, 0.001)
        })
    }
}

testify suite:ライフサイクル付きテストスイート

package repository_test

import (
    "context"
    "testing"

    "github.com/stretchr/testify/suite"
    "myapp/model"
    "myapp/repository"
    "myapp/testutil"
)

type UserRepositorySuite struct {
    suite.Suite // suite.Suiteを埋め込んでassert/requireメソッドを取得

    db   *testutil.TestDB
    repo repository.UserRepository
    ctx  context.Context
}

// SetupSuite すべてのテストの前に1回実行
func (s *UserRepositorySuite) SetupSuite() {
    s.db = testutil.NewTestDB()
    s.db.Migrate()
    s.ctx = context.Background()
}

// TearDownSuite すべてのテストの後に1回実行
func (s *UserRepositorySuite) TearDownSuite() {
    s.db.Close()
}

// SetupTest 各テストの前に実行
func (s *UserRepositorySuite) SetupTest() {
    s.db.TruncateTables()
    s.repo = repository.NewUserRepository(s.db.Conn())
}

// TearDownTest 各テストの後に実行
func (s *UserRepositorySuite) TearDownTest() {
    // テストで生成されたキャッシュ等のクリーンアップ
}

func (s *UserRepositorySuite) TestCreate() {
    user := &model.User{
        Name:  "田中",
        Email: "tanaka@example.com",
    }

    err := s.repo.Create(s.ctx, user)
    s.NoError(err)
    s.NotZero(user.ID)

    found, err := s.repo.GetByID(s.ctx, user.ID)
    s.NoError(err)
    s.Equal("田中", found.Name)
    s.Equal("tanaka@example.com", found.Email)
}

func (s *UserRepositorySuite) TestGetByIDNotFound() {
    _, err := s.repo.GetByID(s.ctx, 99999)
    s.Error(err)
    s.ErrorIs(err, repository.ErrNotFound)
}

func (s *UserRepositorySuite) TestList() {
    users := []*model.User{
        {Name: "田中", Email: "tanaka@example.com"},
        {Name: "佐藤", Email: "sato@example.com"},
        {Name: "鈴木", Email: "suzuki@example.com"},
    }
    for _, u := range users {
        s.NoError(s.repo.Create(s.ctx, u))
    }

    result, err := s.repo.List(s.ctx, repository.Filter{Limit: 10})
    s.NoError(err)
    s.Len(result, 3)
}

// エントリ関数:suite.Runを呼び出す必要がある
func TestUserRepositorySuite(t *testing.T) {
    suite.Run(t, new(UserRepositorySuite))
}

パターン3:Mockとインターフェースシミュレーション

testify/mock 基本的な使い方

package service_test

import (
    "context"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
    "github.com/stretchr/testify/require"
    "myapp/model"
    "myapp/service"
)

// MockUserRepository UserRepositoryインターフェースをシミュレート
type MockUserRepository struct {
    mock.Mock
}

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

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

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

func (m *MockUserRepository) Delete(ctx context.Context, id int64) error {
    args := m.Called(ctx, id)
    return args.Error(0)
}

func (m *MockUserRepository) List(ctx context.Context, filter repository.Filter) ([]*model.User, error) {
    args := m.Called(ctx, filter)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).([]*model.User), args.Error(1)
}

サービス層テスト:呼び出し動作の検証

func TestUserService_GetUser(t *testing.T) {
    mockRepo := new(MockUserRepository)
    svc := service.NewUserService(mockRepo)

    t.Run("ユーザーが存在する", func(t *testing.T) {
        expectedUser := &model.User{ID: 1, Name: "田中", Email: "tanaka@example.com"}

        mockRepo.On("GetByID", mock.Anything, int64(1)).
            Return(expectedUser, nil).Once()

        user, err := svc.GetUser(context.Background(), 1)

        require.NoError(t, err)
        assert.Equal(t, "田中", user.Name)

        mockRepo.AssertExpectations(t)
    })

    t.Run("ユーザーが存在しない", func(t *testing.T) {
        mockRepo.On("GetByID", mock.Anything, int64(999)).
            Return(nil, repository.ErrNotFound).Once()

        _, err := svc.GetUser(context.Background(), 999)

        assert.ErrorIs(t, err, repository.ErrNotFound)
        mockRepo.AssertExpectations(t)
    })
}

func TestUserService_CreateUser_DuplicateEmail(t *testing.T) {
    mockRepo := new(MockUserRepository)
    svc := service.NewUserService(mockRepo)

    mockRepo.On("Create", mock.Anything, mock.MatchedBy(func(u *model.User) bool {
        return u.Email == "duplicate@example.com"
    })).Return(repository.ErrDuplicateEmail).Once()

    err := svc.CreateUser(context.Background(), "田中", "duplicate@example.com")

    assert.ErrorIs(t, err, repository.ErrDuplicateEmail)
    mockRepo.AssertExpectations(t)
}

Mock高度な使い方:マッチャーと呼び出し回数

func TestUserService_ComplexMock(t *testing.T) {
    mockRepo := new(MockUserRepository)
    svc := service.NewUserService(mockRepo)

    mockRepo.On("Create",
        mock.Anything,
        mock.MatchedBy(func(u *model.User) bool {
            return u.Name != "" && u.Email != ""
        }),
    ).Return(nil).Once()

    mockRepo.On("Delete", mock.Anything, mock.Anything).Return(nil).Times(0)

    err := svc.CreateUser(context.Background(), "田中", "tanaka@example.com")
    require.NoError(t, err)

    mockRepo.AssertExpectations(t)

    mockRepo.AssertCalled(t, "Create", mock.Anything, mock.Anything)
    mockRepo.AssertNumberOfCalls(t, "Create", 1)

    mockRepo.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything)
}

パターン4:HTTPハンドラーテスト

httptestでGinハンドラーをテスト

package handler_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"

    "github.com/gin-gonic/gin"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "myapp/handler"
    "myapp/model"
)

func init() {
    gin.SetMode(gin.TestMode)
}

func TestGetUserHandler(t *testing.T) {
    mockSvc := new(MockUserService)
    h := handler.NewUserHandler(mockSvc)

    router := gin.New()
    router.GET("/api/v1/users/:id", h.GetUser)

    t.Run("ユーザーの取得に成功", func(t *testing.T) {
        expectedUser := &model.User{ID: 1, Name: "田中", Email: "tanaka@example.com"}
        mockSvc.On("GetUser", mock.Anything, int64(1)).Return(expectedUser, nil).Once()

        w := httptest.NewRecorder()
        req, _ := http.NewRequest("GET", "/api/v1/users/1", nil)
        router.ServeHTTP(w, req)

        assert.Equal(t, http.StatusOK, w.Code)

        var resp struct {
            Code int         `json:"code"`
            Data *model.User `json:"data"`
        }
        err := json.Unmarshal(w.Body.Bytes(), &resp)
        require.NoError(t, err)
        assert.Equal(t, 0, resp.Code)
        assert.Equal(t, "田中", resp.Data.Name)
        mockSvc.AssertExpectations(t)
    })

    t.Run("ユーザーが存在しない場合は404", func(t *testing.T) {
        mockSvc.On("GetUser", mock.Anything, int64(999)).
            Return(nil, service.ErrUserNotFound).Once()

        w := httptest.NewRecorder()
        req, _ := http.NewRequest("GET", "/api/v1/users/999", nil)
        router.ServeHTTP(w, req)

        assert.Equal(t, http.StatusNotFound, w.Code)
        mockSvc.AssertExpectations(t)
    })
}

func TestCreateUserHandler(t *testing.T) {
    mockSvc := new(MockUserService)
    h := handler.NewUserHandler(mockSvc)

    router := gin.New()
    router.POST("/api/v1/users", h.CreateUser)

    t.Run("作成に成功", func(t *testing.T) {
        mockSvc.On("CreateUser", mock.Anything, "田中", "tanaka@example.com").
            Return(&model.User{ID: 1, Name: "田中", Email: "tanaka@example.com"}, nil).Once()

        body := `{"name":"田中","email":"tanaka@example.com"}`
        w := httptest.NewRecorder()
        req, _ := http.NewRequest("POST", "/api/v1/users", strings.NewReader(body))
        req.Header.Set("Content-Type", "application/json")
        router.ServeHTTP(w, req)

        assert.Equal(t, http.StatusCreated, w.Code)
        mockSvc.AssertExpectations(t)
    })

    t.Run("バリデーション失敗", func(t *testing.T) {
        body := `{"name":"","email":"invalid"}`
        w := httptest.NewRecorder()
        req, _ := http.NewRequest("POST", "/api/v1/users", strings.NewReader(body))
        req.Header.Set("Content-Type", "application/json")
        router.ServeHTTP(w, req)

        assert.Equal(t, http.StatusBadRequest, w.Code)
    })
}

httptest.NewServer:外部HTTPサービスのシミュレーション

package client_test

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

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "myapp/client"
)

func TestWeatherClient_GetWeather(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        assert.Equal(t, "/api/weather", r.URL.Path)
        assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
        assert.Equal(t, "Beijing", r.URL.Query().Get("city"))

        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte(`{"city":"Beijing","temp":25,"humidity":60}`))
    }))
    defer server.Close()

    c := client.NewWeatherClient(server.URL, "test-token")

    weather, err := c.GetWeather(context.Background(), "Beijing")

    require.NoError(t, err)
    assert.Equal(t, "Beijing", weather.City)
    assert.Equal(t, 25, weather.Temp)
    assert.Equal(t, 60, weather.Humidity)
}

パターン5:テストスイートの構成とCI統合

プロジェクトテストディレクトリ構造

myapp/
├── internal/
│   ├── user/
│   │   ├── service.go
│   │   ├── service_test.go
│   │   ├── handler.go
│   │   ├── handler_test.go
│   │   ├── mock_repository.go
│   │   └── testdata/
│   │       ├── create_user_req.json
│   │       └── get_user_resp.json
│   └── order/
│       ├── service.go
│       ├── service_test.go
│       └── suite_test.go
├── integration/
│   └── user_repository_test.go
├── testutil/
│   ├── db.go
│   └── fixture.go
├── Makefile
└── .github/
    └── workflows/
        └── test.yml

Makefileテストコマンド

.PHONY: test test-unit test-integration test-coverage test-race

test-unit:
	go test -v -count=1 -short ./internal/...

test-integration:
	go test -v -count=1 -tags=integration ./integration/...

test-race:
	go test -v -race -count=1 ./...

test-coverage:
	go test -coverprofile=coverage.out -covermode=atomic ./...
	go tool cover -html=coverage.out -o coverage.html
	@echo "カバレッジ: $$(go tool cover -func=coverage.out | grep total | awk '{print $$3}')"

test: test-unit test-coverage

GitHub Actions CI設定

name: Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        go-version: ['1.24', '1.25']
    steps:
      - uses: actions/checkout@v4

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: ${{ matrix.go-version }}

      - name: Cache Go modules
        uses: actions/cache@v4
        with:
          path: |
            ~/go/pkg/mod
            ~/.cache/go-build
          key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }}

      - name: Download dependencies
        run: go mod download

      - name: Vet
        run: go vet ./...

      - name: Test with race detector
        run: go test -race -count=1 -short ./...

      - name: Generate coverage
        run: |
          go test -coverprofile=coverage.out -covermode=atomic ./...
          go tool cover -func=coverage.out

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage.out

テストスイート統合例

package integration_test

import (
    "context"
    "testing"

    "github.com/stretchr/testify/suite"
    "myapp/model"
    "myapp/repository"
    "myapp/testutil"
)

type IntegrationSuite struct {
    suite.Suite
    db    *testutil.TestDB
    repos *repository.Repositories
}

func (s *IntegrationSuite) SetupSuite() {
    s.db = testutil.NewTestDB()
    s.db.Migrate()
    s.repos = repository.NewRepositories(s.db.Conn())
}

func (s *IntegrationSuite) TearDownSuite() {
    s.db.Close()
}

func (s *IntegrationSuite) SetupTest() {
    s.db.TruncateTables()
}

func (s *IntegrationSuite) TestUserCRUD() {
    ctx := context.Background()

    user := &model.User{Name: "田中", Email: "tanaka@example.com"}
    s.NoError(s.repos.User.Create(ctx, user))
    s.NotZero(user.ID)

    found, err := s.repos.User.GetByID(ctx, user.ID)
    s.NoError(err)
    s.Equal("田中", found.Name)

    found.Name = "佐藤"
    s.NoError(s.repos.User.Update(ctx, found))

    updated, _ := s.repos.User.GetByID(ctx, user.ID)
    s.Equal("佐藤", updated.Name)

    s.NoError(s.repos.User.Delete(ctx, user.ID))
    _, err = s.repos.User.GetByID(ctx, user.ID)
    s.Error(err)
}

func TestIntegrationSuite(t *testing.T) {
    suite.Run(t, new(IntegrationSuite))
}

5つのよくある落とし穴

落とし穴1:テーブル駆動テストでrequireを使うと後続のアサーションがスキップされる

間違った書き方

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        result, err := DoSomething(tt.input)
        require.NoError(t, err) // 失敗すると後続のアサーションがスキップされる
        require.Equal(t, tt.want, result)
    })
}

正しい書き方

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        result, err := DoSomething(tt.input)
        assert.NoError(t, err)
        assert.Equal(t, tt.want, result)
    })
}

注意:t.Runは独立したサブテストを作成します。requireが終了させるのはそのサブテストだけで、他のケースには影響しません。ただし、単一のサブテスト内で後続のアサーション結果も確認したい場合は、assertを使用してください。

落とし穴2:AssertExpectationsの呼び出しを忘れる

間違った書き方

func TestWithMock(t *testing.T) {
    m := new(MockRepo)
    m.On("Get", mock.Anything, 1).Return(nil, nil)
    // AssertExpectationsがない!
}

正しい書き方

func TestWithMock(t *testing.T) {
    m := new(MockRepo)
    m.On("Get", mock.Anything, 1).Return(nil, nil).Once()
    // ... mを使用
    m.AssertExpectations(t)
}

落とし穴3:Mockの戻り値の型不一致によるpanic

間違った書き方

m.On("GetByID", mock.Anything, int64(1)).Return(nil, nil)
// nilの型曖昧さ、panicの可能性あり

正しい書き方

m.On("GetByID", mock.Anything, int64(1)).Return(&model.User{ID: 1}, nil)
// 具体的な型の値を明示的に返す

落とし穴4:suite内でt.Runを直接使用

間違った書き方

func (s *MySuite) TestSomething() {
    s.T().Run("subtest", func(t *testing.T) {
        s.Equal(1, 2) // s.T()が間違ったtを指す可能性
    })
}

正しい書き方

func (s *MySuite) TestSomething() {
    s.Equal(1, 1)
    s.NoError(nil)
}

// または独立したテストメソッドに分割
func (s *MySuite) TestSomethingCase1() {
    s.Equal(1, 1)
}

func (s *MySuite) TestSomethingCase2() {
    s.Equal(2, 2)
}

落とし穴5:httptestでContent-Typeの設定を忘れる

間違った書き方

req, _ := http.NewRequest("POST", "/api/users", strings.NewReader(body))
// Content-Typeが設定されていない
router.ServeHTTP(w, req)

正しい書き方

req, _ := http.NewRequest("POST", "/api/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)

エラートラブルシューティングリファレンス

エラーメッセージ 原因 解決策
assert: arguments: Call may need to use WithArgs MockのOnメソッドのパラメータ型が実際の呼び出しと一致しない Onのパラメータ型が実際の呼び出しと一致することを確認、mock.Anythingで任意のパラメータにマッチ
mock: Unexpected Method Call Onで登録されていないMockメソッドが呼び出された Onの登録漏れがないか確認、または.Maybe()でオプション呼び出しをマーク
panic: interface conversion: interface {} is nil, not *X Mock Returnのnilを具象型に変換できない Return(nil, nil)の代わりにReturn(&X{}, nil)を使用
suite: test method should have signature TestXxx(t *testing.T) suiteエントリ関数のシグネチャエラー エントリ関数がfunc TestXxxSuite(t *testing.T)suite.Runを呼び出すことを確認
cannot use m (type *MockRepo) as type Repo in assignment Mock構造体がインターフェースを完全に実装していない mockgenツールでMockを自動生成、すべてのインターフェースメソッドが実装されていることを確認
test timed out after 10m0s Mockメソッドがブロックして待機しているが、呼び出されない ビジネスロジックがMockメソッドを正しく呼び出しているか確認、または.Maybe()を追加
race detected during execution of test テスト内で並行データ競合が発生 -raceフラグでテストを実行、共有状態のアクセスを確認
testify: AssertExpectations failed 登録されたMockメソッドが呼び出されなかった ビジネスロジックでメソッド呼び出しが漏れていないか確認、または.Maybe()でオプション呼び出しをマーク
invalid argument for Int method Mockパラメータで間違った型を使用 1ではなくint64(1)を使用、パラメータ型がインターフェースシグネチャと一致することを確認
go: module github.com/stretchr/testify not found testify依存関係がインストールされていない go get github.com/stretchr/testify@v1.9.0を実行

高度な最適化のヒント

ヒント1:カスタムアサーションで重複コードを削減

package testutil

import (
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
    "myapp/model"
)

// AssertUserEqual カスタムユーザーアサーション、IDとCreatedAtを無視
func AssertUserEqual(t *testing.T, expected, actual *model.User, msgAndArgs ...interface{}) {
    t.Helper()
    assert.Equal(t, expected.Name, actual.Name, msgAndArgs...)
    assert.Equal(t, expected.Email, actual.Email, msgAndArgs...)
    assert.Equal(t, expected.Age, actual.Age, msgAndArgs...)
    if expected.Status != "" {
        assert.Equal(t, expected.Status, actual.Status, msgAndArgs...)
    }
}

// AssertTimeApproximately 時間近似アサーション
func AssertTimeApproximately(t *testing.T, expected, actual time.Time, delta time.Duration, msgAndArgs ...interface{}) {
    t.Helper()
    assert.WithinDuration(t, expected, actual, delta, msgAndArgs...)
}

ヒント2:mockgenでMockを自動生成

# mockgenのインストール
go install go.uber.org/mock/mockgen@latest

# インターフェースのMockを生成
mockgen -source=repository.go -destination=mock_repository.go -package=repository_test

# コメントディレクティブで自動生成
//go:generate mockgen -source=repository.go -destination=mock_repository.go -package=repository_test

ヒント3:ビルドタグによるテストサブセットの実行

// ビルドタグで統合テストを制御
//go:build integration

package integration_test

import "testing"

func TestDatabaseCRUD(t *testing.T) {
    // go test -tags=integration のみで実行
}
# ユニットテストのみ実行
go test -short ./...

# 統合テストを実行
go test -tags=integration ./...

ヒント4:並列テストで高速化

func TestParallel(t *testing.T) {
    t.Parallel()

    tests := []struct{ name string }{
        {name: "case1"},
        {name: "case2"},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // テストロジック
        })
    }
}

ヒント5:Golden Fileテストパターン

package handler_test

import (
    "context"
    "encoding/json"
    "os"
    "path/filepath"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestGetUserResponse(t *testing.T) {
    handler := setupHandler()
    resp := handler.GetUser(context.Background(), 1)

    actual, err := json.MarshalIndent(resp, "", "  ")
    require.NoError(t, err)

    goldenPath := filepath.Join("testdata", "get_user_response.json")

    if os.Getenv("UPDATE_GOLDEN") != "" {
        err = os.WriteFile(goldenPath, actual, 0644)
        require.NoError(t, err)
    }

    expected, err := os.ReadFile(goldenPath)
    require.NoError(t, err)
    assert.Equal(t, string(expected), string(actual))
}
# Golden Fileを更新
UPDATE_GOLDEN=1 go test ./handler/...

テストフレームワーク比較

特徴 testify gomock gocheck 標準testing
アサーションの豊富さ ⭐⭐⭐⭐⭐ 100+メソッド ⭐⭐ 基本的なアサーション ⭐⭐⭐ 比較的豊富 ⭐ t.Errorのみ
Mockサポート ⭐⭐⭐⭐ 組み込みmockパッケージ ⭐⭐⭐⭐⭐ mockgen自動生成 ⭐⭐ 組み込みなし ❌ なし
テストスイート ⭐⭐⭐⭐ suiteパッケージ ❌ なし ⭐⭐⭐⭐⭐ Checker+Fixture ⭐ サブテストのみ
学習曲線 ⭐ 低い、直感的なAPI ⭐⭐⭐ 中程度、mockgenの学習が必要 ⭐⭐⭐ 中程度 ⭐ 最も低い
コミュニティの活発さ ⭐⭐⭐⭐⭐ GitHub 23k+ stars ⭐⭐⭐⭐ Uberメンテナンス ⭐⭐ ニッチ ⭐⭐⭐⭐⭐ 公式
テーブル駆動互換性 ⭐⭐⭐⭐⭐ 完璧 ⭐⭐⭐ 適応が必要 ⭐⭐⭐ 適応が必要 ⭐⭐⭐⭐ ネイティブサポート
HTTPテスト ⭐⭐⭐⭐ httptestと組み合わせ ⭐⭐⭐ httptestと組み合わせ ⭐⭐⭐ httptestと組み合わせ ⭐⭐⭐ httptestと組み合わせ
IDEサポート ⭐⭐⭐⭐⭐ 全IDE対応 ⭐⭐⭐⭐ 全IDE対応 ⭐⭐ 部分対応 ⭐⭐⭐⭐⭐ 全IDE対応
ユースケース 汎用テスト、クイックスタート インターフェースMock集約型プロジェクト 複雑なFixtureが必要なプロジェクト シンプルなプロジェクト、ミニマリズム

まとめ

Goテストの核心的な課題は「アサーションの書き方」ではなく「テストの構成方法」です。assertとrequireはアサーションの可読性を解決し、suiteはテストのライフサイクル管理を解決し、mockは依存関係の分離を解決し、httptestはHTTPテストの信頼性を解決し、CI統合はテストの継続性を解決します。この5つが組み合わさって初めて、プロダクション級Goテストの完全な方法論が成り立ちます。覚えておいてください:良いテストとは、たくさん書かれたテストではなく、失敗した時に問題を正確に特定できるテストです。


関連ツール

  • JSONフォーマッター — APIレスポンスJSONをフォーマット、テストアサーションのデータ構造を素早く検証
  • ハッシュ計算ツール — テストデータの署名とチェックサムを計算、Mockデータの整合性を確保
  • cURL→コード変換 — cURLコマンドをGo HTTPテストコードに変換、ハンドラーテストの作成を加速

ブラウザローカルツールを無料で試す →

#Go测试#testify#Go单元测试#表驱动测试#2026#编程语言