- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに
ソフトウェアの品質はテストによって証明されます。Goは言語自体に強力なテストツールを内蔵しており、testingパッケージとgo testコマンドだけでプロダクションレベルのテストを書けます。
このガイドでは、基本的なユニットテストから、Mock/Stubを活用した依存性の分離、統合テストまで、すぐに実践で使えるパターンを解説します。
1. Goテストの基礎
1.1 testingパッケージ
Goのテストは_test.goファイルに記述し、テスト関数はTestプレフィックスで始まります。
// calculator.go
package calculator
import "fmt"
func Add(a, b int) int { return a + b }
func Sub(a, b int) int { return a - b }
func Mul(a, b int) int { return a * b }
func Div(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil
}
// calculator_test.go
package calculator
import (
"testing"
)
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}
// t.Fatal: 以降のテストコードの実行を即座に停止
func TestDiv(t *testing.T) {
result, err := Div(10, 2)
if err != nil {
t.Fatalf("予期しないエラー: %v", err)
}
if result != 5.0 {
t.Errorf("Div(10, 2) = %f; want 5.0", result)
}
// ゼロ除算エラーのテスト
_, err = Div(10, 0)
if err == nil {
t.Error("ゼロ除算ではエラーを返すべきです")
}
}
// t.Helper(): ヘルパー関数内でのエラー位置をヘルパー内部ではなく呼び出し元に表示
func assertEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
func TestMul(t *testing.T) {
assertEqual(t, Mul(3, 4), 12)
assertEqual(t, Mul(0, 100), 0)
assertEqual(t, Mul(-2, 5), -10)
}
1.2 テストの実行
# 現在のパッケージをテスト
go test
# すべてのパッケージをテスト
go test ./...
# 詳細出力
go test -v
# 特定のテスト関数のみ実行 (正規表現)
go test -run TestAdd
go test -run TestDiv
# テストカバレッジ
go test -cover
go test -coverprofile=coverage.out
go tool cover -html=coverage.out # ブラウザで確認
go tool cover -func=coverage.out # 関数別カバレッジ
# タイムアウト設定
go test -timeout 30s
# レースコンディション検出
go test -race
# キャッシュを無視
go test -count=1 ./...
1.3 TestMain
package mypackage_test
import (
"fmt"
"os"
"testing"
)
var testDB *Database
// TestMain: テストスイート全体のセットアップとティアダウン
func TestMain(m *testing.M) {
// セットアップ
fmt.Println("テスト環境のセットアップ開始")
testDB = setupTestDatabase()
// テスト実行
code := m.Run()
// ティアダウン
testDB.Close()
fmt.Println("テスト環境のクリーンアップ完了")
os.Exit(code)
}
2. テーブル駆動テスト (Table-Driven Tests)
Goで最もイディオマティックなテストパターンです。
package calculator_test
import (
"fmt"
"testing"
"github.com/example/calculator"
)
func TestAdd_TableDriven(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"正の数同士", 2, 3, 5},
{"正と負", 5, -3, 2},
{"負の数同士", -4, -6, -10},
{"ゼロケース", 0, 0, 0},
{"大きな数", 1000000, 2000000, 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := calculator.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 TestDiv_TableDriven(t *testing.T) {
tests := []struct {
name string
a, b float64
expected float64
expectErr bool
}{
{"通常の除算", 10, 2, 5.0, false},
{"小数の結果", 7, 2, 3.5, false},
{"ゼロ除算", 10, 0, 0, true},
{"負の数", -10, 2, -5.0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := calculator.Div(tt.a, tt.b)
if tt.expectErr {
if err == nil {
t.Error("エラーを期待しましたが、nilが返されました")
}
return
}
if err != nil {
t.Fatalf("予期しないエラー: %v", err)
}
if result != tt.expected {
t.Errorf("Div(%.1f, %.1f) = %.1f; want %.1f",
tt.a, tt.b, result, tt.expected)
}
})
}
}
// サブテストごとのセットアップとティアダウン
func TestUserService_TableDriven(t *testing.T) {
tests := []struct {
name string
userID int
wantErr bool
}{
{"有効なユーザー", 1, false},
{"存在しないユーザー", 999, true},
{"負のID", -1, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// サブテストごとのセットアップ
svc := NewTestUserService()
defer svc.Cleanup()
user, err := svc.GetUser(tt.userID)
if (err != nil) != tt.wantErr {
t.Errorf("GetUser(%d) error = %v, wantErr %v",
tt.userID, err, tt.wantErr)
return
}
if !tt.wantErr {
fmt.Printf("ユーザー: %+v\n", user)
}
})
}
}
3. testifyライブラリ
testifyはGoで最も広く使われているテストライブラリです。
go get github.com/stretchr/testify
3.1 assertとrequireの違い
package mypackage_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithTestify(t *testing.T) {
result := Add(2, 3)
// assert: 失敗してもテストを継続
assert.Equal(t, 5, result, "2+3は5であるべきです")
assert.NotEqual(t, 6, result)
assert.True(t, result > 0)
assert.False(t, result < 0)
// require: 失敗したらテストを即座に停止
require.NotNil(t, result)
require.Equal(t, 5, result)
}
func TestCollections(t *testing.T) {
nums := []int{1, 2, 3, 4, 5}
assert.Contains(t, nums, 3)
assert.NotContains(t, nums, 10)
assert.Len(t, nums, 5)
assert.Empty(t, []int{})
assert.NotEmpty(t, nums)
m := map[string]int{"a": 1, "b": 2}
assert.Equal(t, 1, m["a"])
}
func TestErrors(t *testing.T) {
_, err := Div(10, 0)
assert.Error(t, err)
assert.EqualError(t, err, "division by zero")
result, err := Div(10, 2)
assert.NoError(t, err)
assert.Equal(t, 5.0, result)
}
func TestPanic(t *testing.T) {
assert.Panics(t, func() {
panic("test panic")
})
assert.NotPanics(t, func() {
_ = Add(1, 2)
})
}
3.2 assert.JSONEqとHTTPアサーション
package mypackage_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestJSONResponse(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"name":"Alice","age":30}`))
}
req := httptest.NewRequest(http.MethodGet, "/user", nil)
rec := httptest.NewRecorder()
handler(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
// キーの順序に関係なくJSON内容を比較
expected := `{"age": 30, "name": "Alice"}`
assert.JSONEq(t, expected, rec.Body.String())
}
3.3 testify/suite
package mypackage_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
type UserServiceSuite struct {
suite.Suite
service *UserService
db *TestDB
}
// スイート開始前に1回実行
func (s *UserServiceSuite) SetupSuite() {
s.db = NewTestDB()
}
// 各テスト前に実行
func (s *UserServiceSuite) SetupTest() {
s.service = NewUserService(s.db)
s.db.Seed()
}
// 各テスト後に実行
func (s *UserServiceSuite) TearDownTest() {
s.db.Clean()
}
// スイート終了後に1回実行
func (s *UserServiceSuite) TearDownSuite() {
s.db.Close()
}
func (s *UserServiceSuite) TestGetUser() {
user, err := s.service.GetUser(1)
s.NoError(err)
s.Equal("Alice", user.Name)
}
func (s *UserServiceSuite) TestCreateUser() {
user, err := s.service.CreateUser("Bob", "bob@example.com")
s.NoError(err)
s.NotZero(user.ID)
s.Equal("Bob", user.Name)
}
func (s *UserServiceSuite) TestDeleteUser_NotFound() {
err := s.service.DeleteUser(9999)
s.Error(err)
assert.Contains(s.T(), err.Error(), "not found")
}
// GoテストランナーにスイートをRegister
func TestUserServiceSuite(t *testing.T) {
suite.Run(t, new(UserServiceSuite))
}
4. インターフェースを使ったMock
4.1 インターフェースベースの依存性注入
// user_repository.go
package user
import (
"context"
"fmt"
)
type User struct {
ID int
Name string
Email string
}
// インターフェース定義 (依存性注入の核心)
type Repository interface {
FindByID(ctx context.Context, id int) (*User, error)
Save(ctx context.Context, user *User) error
Delete(ctx context.Context, id int) error
List(ctx context.Context) ([]*User, error)
}
type EmailSender interface {
Send(to, subject, body string) error
}
// サービスはインターフェースに依存 (具体的な実装には依存しない)
type Service struct {
repo Repository
email EmailSender
}
func NewService(repo Repository, email EmailSender) *Service {
return &Service{repo: repo, email: email}
}
func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
return s.repo.FindByID(ctx, id)
}
func (s *Service) CreateUser(ctx context.Context, name, email string) (*User, error) {
user := &User{Name: name, Email: email}
if err := s.repo.Save(ctx, user); err != nil {
return nil, fmt.Errorf("ユーザーの保存: %w", err)
}
// ウェルカムメール送信 (失敗してもユーザー作成は成功)
_ = s.email.Send(email, "ようこそ!", "ご登録ありがとうございます!")
return user, nil
}
4.2 手動Mock実装
// テストファイルで直接定義可能
package user_test
import (
"context"
"fmt"
"github.com/example/user"
)
type MockRepository struct {
users map[int]*user.User
saveErr error
findErr error
}
func NewMockRepository() *MockRepository {
return &MockRepository{users: make(map[int]*user.User)}
}
func (m *MockRepository) FindByID(ctx context.Context, id int) (*user.User, error) {
if m.findErr != nil {
return nil, m.findErr
}
u, ok := m.users[id]
if !ok {
return nil, fmt.Errorf("user %d not found", id)
}
return u, nil
}
func (m *MockRepository) Save(ctx context.Context, u *user.User) error {
if m.saveErr != nil {
return m.saveErr
}
if u.ID == 0 {
u.ID = len(m.users) + 1
}
m.users[u.ID] = u
return nil
}
func (m *MockRepository) Delete(ctx context.Context, id int) error {
delete(m.users, id)
return nil
}
func (m *MockRepository) List(ctx context.Context) ([]*user.User, error) {
users := make([]*user.User, 0, len(m.users))
for _, u := range m.users {
users = append(users, u)
}
return users, nil
}
type MockEmailSender struct {
SentEmails []struct{ To, Subject, Body string }
SendErr error
}
func (m *MockEmailSender) Send(to, subject, body string) error {
if m.SendErr != nil {
return m.SendErr
}
m.SentEmails = append(m.SentEmails, struct{ To, Subject, Body string }{to, subject, body})
return nil
}
5. gomockの使い方
5.1 gomockのインストールとコード生成
go install go.uber.org/mock/mockgen@latest
go get go.uber.org/mock/gomock
インターフェースファイルにgo:generateディレクティブを追加:
// repository.go
package user
//go:generate mockgen -source=repository.go -destination=mocks/mock_repository.go -package=mocks
type Repository interface {
FindByID(ctx context.Context, id int) (*User, error)
Save(ctx context.Context, user *User) error
Delete(ctx context.Context, id int) error
List(ctx context.Context) ([]*User, error)
}
# Mockを生成
go generate ./...
5.2 gomockを使ったテスト
package user_test
import (
"context"
"fmt"
"testing"
"github.com/example/user"
"github.com/example/user/mocks"
"go.uber.org/mock/gomock"
"github.com/stretchr/testify/assert"
)
func TestGetUser_WithGomock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mocks.NewMockRepository(ctrl)
mockEmail := mocks.NewMockEmailSender(ctrl)
expectedUser := &user.User{ID: 1, Name: "Alice", Email: "alice@example.com"}
// EXPECT: メソッド呼び出しの期待設定
mockRepo.EXPECT().
FindByID(gomock.Any(), 1).
Return(expectedUser, nil).
Times(1)
svc := user.NewService(mockRepo, mockEmail)
got, err := svc.GetUser(context.Background(), 1)
assert.NoError(t, err)
assert.Equal(t, expectedUser, got)
}
func TestCreateUser_WithGomock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mocks.NewMockRepository(ctrl)
mockEmail := mocks.NewMockEmailSender(ctrl)
// Saveはちょうど1回呼ばれなければならない
mockRepo.EXPECT().
Save(gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, u *user.User) error {
u.ID = 42 // IDの割り当てをシミュレート
return nil
}).
Times(1)
// Sendは0回以上呼ばれてよい
mockEmail.EXPECT().
Send(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil).
AnyTimes()
svc := user.NewService(mockRepo, mockEmail)
got, err := svc.CreateUser(context.Background(), "Bob", "bob@example.com")
assert.NoError(t, err)
assert.Equal(t, 42, got.ID)
assert.Equal(t, "Bob", got.Name)
}
// 特定の引数マッチャーを使用
func TestFindByID_SpecificArgs(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mocks.NewMockRepository(ctrl)
// 正確な引数マッチング
mockRepo.EXPECT().
FindByID(gomock.Any(), gomock.Eq(5)).
Return(nil, fmt.Errorf("not found")).
Times(1)
// カスタムマッチャー
mockRepo.EXPECT().
FindByID(gomock.Any(), gomock.Not(gomock.Eq(0))).
Return(&user.User{ID: 10}, nil).
AnyTimes()
}
6. testify/mockの使い方
6.1 testify/mockを使ったMock実装
package user_test
import (
"context"
"testing"
"github.com/example/user"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/assert"
)
// mock.Mockを埋め込んだMock構造体
type MockUserRepo struct {
mock.Mock
}
func (m *MockUserRepo) FindByID(ctx context.Context, id int) (*user.User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*user.User), args.Error(1)
}
func (m *MockUserRepo) Save(ctx context.Context, u *user.User) error {
args := m.Called(ctx, u)
return args.Error(0)
}
func (m *MockUserRepo) Delete(ctx context.Context, id int) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func (m *MockUserRepo) List(ctx context.Context) ([]*user.User, error) {
args := m.Called(ctx)
return args.Get(0).([]*user.User), args.Error(1)
}
func TestGetUser_Testify(t *testing.T) {
mockRepo := new(MockUserRepo)
mockEmail := new(MockEmailSender)
expectedUser := &user.User{ID: 1, Name: "Alice"}
// On()でメソッドの動作を設定
mockRepo.On("FindByID", mock.Anything, 1).
Return(expectedUser, nil)
svc := user.NewService(mockRepo, mockEmail)
got, err := svc.GetUser(context.Background(), 1)
assert.NoError(t, err)
assert.Equal(t, expectedUser, got)
// すべての期待が満たされたか確認
mockRepo.AssertExpectations(t)
}
func TestCreateUser_Testify(t *testing.T) {
mockRepo := new(MockUserRepo)
mockEmail := new(MockEmailSender)
// mock.MatchedByでカスタムマッチャーを使用
mockRepo.On("Save", mock.Anything, mock.MatchedBy(func(u *user.User) bool {
return u.Name == "Bob"
})).Return(nil)
mockEmail.On("Send", mock.Anything, mock.Anything, mock.Anything).Return(nil)
svc := user.NewService(mockRepo, mockEmail)
_, err := svc.CreateUser(context.Background(), "Bob", "bob@example.com")
assert.NoError(t, err)
mockRepo.AssertNumberOfCalls(t, "Save", 1)
mockEmail.AssertNumberOfCalls(t, "Send", 1)
}
7. HTTPモックサーバー (httptest)
7.1 httptest.NewRecorder
package handler_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestUserHandler_GetAll(t *testing.T) {
store := NewInMemoryStore()
store.Add(&User{ID: 1, Name: "Alice"})
store.Add(&User{ID: 2, Name: "Bob"})
handler := NewUserHandler(store)
req := httptest.NewRequest(http.MethodGet, "/users", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
var users []User
err := json.NewDecoder(rec.Body).Decode(&users)
assert.NoError(t, err)
assert.Len(t, users, 2)
}
func TestUserHandler_Create(t *testing.T) {
store := NewInMemoryStore()
handler := NewUserHandler(store)
body := map[string]string{
"name": "Charlie",
"email": "charlie@example.com",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/users",
bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusCreated, rec.Code)
var created User
json.NewDecoder(rec.Body).Decode(&created)
assert.Equal(t, "Charlie", created.Name)
assert.NotZero(t, created.ID)
}
7.2 httptest.NewServer (外部APIのモック)
package client_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestHTTPClient_GetUser(t *testing.T) {
// 外部サービスをモックするテストサーバー
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// リクエストの検証
assert.Equal(t, "/api/users/1", r.URL.Path)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
})
}))
defer ts.Close()
client := NewUserClient(ts.URL, "test-token")
user, err := client.GetUser(1)
assert.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
}
func TestHTTPClient_ServerError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}))
defer ts.Close()
client := NewUserClient(ts.URL, "test-token")
_, err := client.GetUser(1)
assert.Error(t, err)
}
func TestHTTPClient_MultipleEndpoints(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]User{{ID: 1, Name: "Alice"}, {ID: 2, Name: "Bob"}})
})
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
ts := httptest.NewServer(mux)
defer ts.Close()
client := NewUserClient(ts.URL, "token")
users, err := client.ListUsers()
assert.NoError(t, err)
assert.Len(t, users, 2)
}
8. Stubパターン
8.1 インターフェースベースのStub
// 時刻Stub: time.Now()を置き換え可能にする
type Clock interface {
Now() time.Time
}
type RealClock struct{}
func (r RealClock) Now() time.Time { return time.Now() }
type FakeClock struct {
fakeTime time.Time
}
func (f FakeClock) Now() time.Time { return f.fakeTime }
// ファイルシステムStub
type FileSystem interface {
ReadFile(name string) ([]byte, error)
WriteFile(name string, data []byte, perm os.FileMode) error
Exists(name string) bool
}
type OSFileSystem struct{}
func (f OSFileSystem) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) }
func (f OSFileSystem) WriteFile(name string, data []byte, perm os.FileMode) error {
return os.WriteFile(name, data, perm)
}
func (f OSFileSystem) Exists(name string) bool {
_, err := os.Stat(name)
return !os.IsNotExist(err)
}
// インメモリファイルシステムStub
type MemoryFileSystem struct {
files map[string][]byte
}
func NewMemoryFileSystem() *MemoryFileSystem {
return &MemoryFileSystem{files: make(map[string][]byte)}
}
func (m *MemoryFileSystem) ReadFile(name string) ([]byte, error) {
data, ok := m.files[name]
if !ok {
return nil, fmt.Errorf("ファイルが見つかりません: %s", name)
}
return data, nil
}
func (m *MemoryFileSystem) WriteFile(name string, data []byte, perm os.FileMode) error {
m.files[name] = data
return nil
}
func (m *MemoryFileSystem) Exists(name string) bool {
_, ok := m.files[name]
return ok
}
// Stubを使ったサービステスト
type ConfigService struct {
fs FileSystem
clock Clock
}
func (s *ConfigService) LoadConfig(path string) (*Config, error) {
data, err := s.fs.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
cfg.LoadedAt = s.clock.Now()
return &cfg, nil
}
8.2 データベースStub (sqlmock)
package repository_test
import (
"context"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
)
func TestUserRepository_FindByID(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmockの作成に失敗: %v", err)
}
defer db.Close()
rows := sqlmock.NewRows([]string{"id", "name", "email"}).
AddRow(1, "Alice", "alice@example.com")
mock.ExpectQuery(regexp.QuoteMeta("SELECT id, name, email FROM users WHERE id = ?")).
WithArgs(1).
WillReturnRows(rows)
repo := NewSQLUserRepository(db)
user, err := repo.FindByID(context.Background(), 1)
assert.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
assert.NoError(t, mock.ExpectationsWereMet())
}
func TestUserRepository_Save(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
mock.ExpectExec(regexp.QuoteMeta(
"INSERT INTO users (name, email) VALUES (?, ?)")).
WithArgs("Bob", "bob@example.com").
WillReturnResult(sqlmock.NewResult(42, 1))
repo := NewSQLUserRepository(db)
user := &User{Name: "Bob", Email: "bob@example.com"}
err := repo.Save(context.Background(), user)
assert.NoError(t, err)
assert.Equal(t, 42, user.ID)
assert.NoError(t, mock.ExpectationsWereMet())
}
9. モックサーバーの構築
// mockserver/server.go
package mockserver
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
)
type RequestRecord struct {
Method string
Path string
Headers http.Header
Body []byte
}
type MockResponse struct {
Status int
Headers map[string]string
Body interface{}
}
type Route struct {
Method string
Path string
Response MockResponse
}
type MockServer struct {
t *testing.T
server *httptest.Server
routes []Route
requests []RequestRecord
mu sync.Mutex
}
func New(t *testing.T) *MockServer {
ms := &MockServer{t: t}
mux := http.NewServeMux()
mux.HandleFunc("/", ms.handleRequest)
ms.server = httptest.NewServer(mux)
t.Cleanup(ms.server.Close)
return ms
}
func (ms *MockServer) handleRequest(w http.ResponseWriter, r *http.Request) {
ms.mu.Lock()
defer ms.mu.Unlock()
body, _ := io.ReadAll(r.Body)
ms.requests = append(ms.requests, RequestRecord{
Method: r.Method,
Path: r.URL.Path,
Headers: r.Header.Clone(),
Body: body,
})
for _, route := range ms.routes {
if route.Method == r.Method && route.Path == r.URL.Path {
for k, v := range route.Response.Headers {
w.Header().Set(k, v)
}
if _, ok := route.Response.Headers["Content-Type"]; !ok {
w.Header().Set("Content-Type", "application/json")
}
w.WriteHeader(route.Response.Status)
if route.Response.Body != nil {
json.NewEncoder(w).Encode(route.Response.Body)
}
return
}
}
ms.t.Errorf("予期しないリクエスト: %s %s", r.Method, r.URL.Path)
http.Error(w, "Not Found", http.StatusNotFound)
}
func (ms *MockServer) AddRoute(method, path string, resp MockResponse) *MockServer {
ms.mu.Lock()
defer ms.mu.Unlock()
ms.routes = append(ms.routes, Route{Method: method, Path: path, Response: resp})
return ms
}
func (ms *MockServer) URL() string {
return ms.server.URL
}
func (ms *MockServer) Requests() []RequestRecord {
ms.mu.Lock()
defer ms.mu.Unlock()
return append([]RequestRecord{}, ms.requests...)
}
func (ms *MockServer) AssertRequestCount(t *testing.T, method, path string, count int) {
t.Helper()
actual := 0
for _, r := range ms.Requests() {
if r.Method == method && r.Path == path {
actual++
}
}
if actual != count {
t.Errorf("%s %s: リクエスト回数 = %d, want %d", method, path, actual, count)
}
}
// 使用例
func TestWithMockServer(t *testing.T) {
ms := New(t)
ms.AddRoute("GET", "/api/users", MockResponse{
Status: 200,
Body: []map[string]interface{}{{"id": 1, "name": "Alice"}},
})
ms.AddRoute("POST", "/api/users", MockResponse{
Status: 201,
Body: map[string]interface{}{"id": 2, "name": "Bob"},
})
client := NewAPIClient(ms.URL())
users, err := client.GetUsers()
assert.NoError(t, err)
assert.Len(t, users, 1)
ms.AssertRequestCount(t, "GET", "/api/users", 1)
}
10. ベンチマークテスト
package calculator_test
import (
"fmt"
"strings"
"testing"
)
// ベンチマーク: go test -bench=. で実行
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(100, 200)
}
}
func BenchmarkFibonacci(b *testing.B) {
sizes := []int{10, 20, 30}
for _, n := range sizes {
n := n
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
Fibonacci(n)
}
})
}
}
// メモリアロケーションの追跡
func BenchmarkStringConcat(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var s string
for j := 0; j < 100; j++ {
s += "x"
}
_ = s
}
}
func BenchmarkStringBuilder(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var sb strings.Builder
for j := 0; j < 100; j++ {
sb.WriteString("x")
}
_ = sb.String()
}
}
11. クイズ
クイズ1: assertとrequireの違い
assert.Equalとrequire.Equalの違いは何ですか?それぞれいつ使うべきですか?
答え:
assert.Equal: 失敗してもテストを継続します。内部でt.Error()を呼び出します。require.Equal: 失敗したらテストを即座に停止します。内部でt.FailNow()を呼び出します。
解説:
requireは後続のコードが前提条件に依存する場合に使います:
func TestUserCreation(t *testing.T) {
user, err := CreateUser("Alice", "alice@example.com")
require.NoError(t, err) // エラーがあればuserはnilでパニックになる
require.NotNil(t, user)
assert.Equal(t, "Alice", user.Name) // 独立した検証にはassertを使用
assert.NotEmpty(t, user.ID)
}
assertは複数の独立した項目をすべて確認したい場合に使います。すべての失敗をまとめて確認できます。
クイズ2: テーブル駆動テストのクロージャの落とし穴
次のテーブル駆動テストのバグは何ですか?
tests := []struct {
name string
input int
want int
}{
{"double 2", 2, 4},
{"double 5", 5, 10},
{"double 10", 10, 20},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := Double(tt.input)
assert.Equal(t, tt.want, result)
})
}
答え: t.Parallel()使用時に、クロージャがループ変数ttを共有します。
解説: t.Parallel()を呼び出すとサブテストが並行して実行されます。クロージャはttを参照でキャプチャするため、すべてのゴルーチンが同じ変数を共有します。ループが早く終了すると最後の値だけが残り、すべてのテストが同じ入力で実行される可能性があります。
修正方法:
for _, tt := range tests {
tt := tt // ループ変数をローカル変数にキャプチャ
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := Double(tt.input)
assert.Equal(t, tt.want, result)
})
}
Go 1.22以降ではループ変数のセマンティクスが変更され、この問題は解消されています。
クイズ3: MockとStubの違い
MockとStubの違いを説明し、それぞれいつ使うべきかを述べてください。
答え:
- Stub: 事前に設定された応答を返すシンプルな代替物。状態検証 (state verification) に使用。
- Mock: 特定のメソッドが特定の引数で呼ばれたか検証するオブジェクト。振る舞い検証 (behavior verification) に使用。
解説:
Stubの例 — 固定応答を返す:
type StubUserRepo struct{}
func (s StubUserRepo) FindByID(ctx context.Context, id int) (*User, error) {
return &User{ID: id, Name: "Test User"}, nil
}
Mockの例 — 呼び出しを検証:
mockRepo.EXPECT().FindByID(ctx, 1).Return(user, nil).Times(1)
// テスト後にFindByIDがちょうど1回呼ばれたか自動で検証
原則: メソッドの呼び出し有無・回数・引数を検証する必要があればMock、単に特定の応答が必要なだけであればStubを使います。
クイズ4: go test -race フラグの役割
go test -raceは何を検出し、いつ使うべきですか?
答え: レースコンディション(Race Condition)を検出します。複数のゴルーチンが同期なしに同じメモリ位置に並行してアクセスする場合を検知します。
解説:
// このコードは -race フラグで検出される
var counter int
func TestRaceCondition(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // DATA RACE: 同期なしで書き込み
}()
}
wg.Wait()
}
-raceフラグは実行時オーバーヘッドが大きいため (5〜10倍遅くなる)、CI/CDパイプラインで実行するか、並行処理コードを書くときに定期的に使用するのがよいでしょう。
クイズ5: テストカバレッジ100%の落とし穴
テストカバレッジ100%はコードにバグがないことを保証しますか?カバレッジ測定時の注意点は?
答え: いいえ。カバレッジ100%はバグがないことを保証しません。
解説: カバレッジはコードが実行されたかどうかを測定するものであり、正しい動作を検証するものではありません。
func Divide(a, b int) int {
return a / b
}
// カバレッジ100%だが重要なケースを見落としている
func TestDivide(t *testing.T) {
assert.Equal(t, 5, Divide(10, 2))
// b=0のケースをテストしていないため、実行時にパニックになる可能性がある
}
カバレッジより重要なこと:
- 境界値テスト (0, -1, 最大値など)
- エラーパスのテスト
- 並行処理のテスト
- 統合テスト
カバレッジはテストされていないコードを見つけるためのツールであり、テストの品質を保証するものではありません。意味のあるテストで70%のカバレッジを達成する方が、形式的なテストで100%を達成するよりはるかに価値があります。