- Go: 標準testing パッケージ
- GORM: ORM(データベース操作)
- MySQL: データベース
- Cobra: CLI フレームワーク
- Gin: HTTP API フレームワーク
このプロジェクトのテスト方針は、いわゆる「古典派(Classicist)」です。
- 実DB(テスト用DB)を使用: DBアクセスを伴うテストは実DB(テスト用DB)を使用
- プロセス内処理はモック不要: DBレイヤーやリポジトリなど、プロセス内で完結する処理にモックを使用しない
- プロセス外のみモック: 外部API・他システム連携・メール送信・キュー等、プロセス外との結合のみモック/スタブを利用
- 内部実装のモック禁止: DBモックやリポジトリモックをテストコードの外側に公開しない(内部実装に依存するモックは原則禁止)
- 状態検証中心: ロンドン派(Mockist)ではなく、状態検証中心のテストを優先
- 実際のMySQLを使用: トランザクションやロールバックを活用してテストを独立させる
実装コードを書く前に、必ずテストコードを先に作成する。
- ユーザーストーリーや仕様を確認
- 期待される動作を明確にする
- データベーススキーマを設計
- 正常系、異常系、境界値をリストアップ
- データベース操作のテストケースを洗い出す
- Cobra コマンドの入出力を定義
- Gin API の HTTP 入出力(ステータスコード、レスポンスJSON)を定義
_test.goファイルに失敗するテストを書く- テーブル駆動テストで複数のケースをカバー
- モックやテストDBを準備
- テストから必要な関数・メソッドのシグネチャを決める
- リポジトリのインターフェースを定義
- 実装すべきAPIを明確にする
domain: エンティティ・ドメインルールusecase: アプリケーション固有のユースケースinterface: Gin ハンドラーや DTO などの入出力層infrastructure: GORM/MySQL などの永続化実装- 依存方向は外側から内側のみ(
interface -> usecase -> domain)
// user_test.go(実装前に作成)
package user
import "testing"
func TestGetUserByID(t *testing.T) {
t.Run("ユーザー取得_ID指定", func(t *testing.T) {
// まだ GetUserByID は存在しない状態
userID := 1
user, err := GetUserByID(userID)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if user.ID != userID {
t.Errorf("expected user ID %d, got %d", userID, user.ID)
}
})
}
// この時点で go test を実行すると失敗する(関数が未定義)// user_test.go
func TestValidateUser(t *testing.T) {
tests := []struct {
name string
input User
wantErr bool
}{
{"正常なユーザー", User{Name: "John", Email: "john@example.com"}, false},
{"名前が空", User{Name: "", Email: "john@example.com"}, true},
{"無効なメール", User{Name: "John", Email: "invalid"}, true},
{"長すぎる名前", User{Name: string(make([]byte, 256)), Email: "test@example.com"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUser(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}// repository/user.go(インターフェースのみ先に定義)
package repository
type UserRepository interface {
FindByID(id int) (*User, error)
FindByEmail(email string) (*User, error)
Create(user *User) error
Update(user *User) error
Delete(id int) error
}
// この時点ではインターフェースのみで、実装はまだない// repository/user_test.go
package repository
import (
"testing"
"gorm.io/gorm"
)
func TestUserRepository_Create(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
repo := NewUserRepository(db) // まだ実装されていない
user := &User{Name: "Test", Email: "test@example.com"}
err := repo.Create(user)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
if user.ID == 0 {
t.Error("expected user ID to be set")
}
}
func TestUserRepository_FindByID(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
repo := NewUserRepository(db)
// 事前データ投入
user := &User{Name: "Test", Email: "test@example.com"}
repo.Create(user)
// テスト実行
found, err := repo.FindByID(user.ID)
if err != nil {
t.Fatalf("failed to find user: %v", err)
}
if found.Name != user.Name {
t.Errorf("expected name %s, got %s", user.Name, found.Name)
}
}// cmd/user_test.go
package cmd
import (
"bytes"
"testing"
)
func TestUserCreateCommand(t *testing.T) {
cmd := NewUserCreateCommand() // まだ実装されていない
buf := new(bytes.Buffer)
cmd.SetOut(buf)
cmd.SetArgs([]string{"--name", "John", "--email", "john@example.com"})
err := cmd.Execute()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
output := buf.String()
if !strings.Contains(output, "User created successfully") {
t.Errorf("expected success message, got %s", output)
}
}// test/helper.go
package test
import (
"testing"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func SetupTestDB(t *testing.T) *gorm.DB {
dsn := "root:password@tcp(localhost:3306)/test_db?parseTime=true"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("failed to connect database: %v", err)
}
// マイグレーション
db.AutoMigrate(&User{})
return db
}
func CleanupTestDB(t *testing.T, db *gorm.DB) {
db.Exec("DELETE FROM users")
}
func CreateTestUser(t *testing.T, db *gorm.DB, name, email string) *User {
user := &User{Name: name, Email: email}
if err := db.Create(user).Error; err != nil {
t.Fatalf("failed to create test user: %v", err)
}
return user
}- テストは実装前に必ず作成
- テスト関数名は英語で記載し、実行名(
t.Runの名前)は日本語で記載- 理由: Go の命名規則と可読性を保ちつつ、Test Explorer 上では仕様単位で視認しやすくするため
- 例:
func TestCalculateAmount(t *testing.T)の中でt.Run("金額計算_手数料込み", ...)
_test.goファイルを先に作る- インターフェースを先に定義(外部API用、内部実装のモック用ではない)
- テストコードは読みやすく保守しやすい構造
- API実装(Gin)はクリーンアーキテクチャで構成する
- Gin ハンドラーにビジネスロジックを直接書かない(usecase 経由で実行)
- DBアクセスは実DB(テスト用DB)を使用
- 集計・絞り込み・JOIN・存在判定は、まず SQL で解決を検討する: SQL で自然に表現できる処理は、無理に GORM やアプリケーション側の後処理で組み立てない
- 複雑なクエリは生クエリを選択してよい: GORM の表現が不自然、可読性が低い、または保守しにくい場合は、
Raw/Execなどの生クエリを使用する - 監査カラムは原則必須: 全テーブルに
created_atとupdate_atを付与する - 履歴系テーブルは例外: 履歴系テーブルには
update_atを付与しない - status はマスタ管理:
CHECK制約ではなく status マスタテーブルを作成し、外部キーで参照する - プロセス内処理(DBレイヤー、リポジトリ、サービス層)はモックしない
- プロセス外結合(外部API、メール送信、キュー等)のみモック/スタブを利用
- DBモックやリポジトリモックをテストコードの外側に公開しない
- 一つの実装に複数のテストファイルがあってもOK
- 単体テスト:
user_test.go - 統合テスト:
user_integration_test.go - E2Eテスト:
user_e2e_test.go
- 単体テスト:
# テストを実行(失敗することを確認)
go test ./...
# カバレッジ付き
go test -cover ./...
# 詳細表示
go test -v ./...
# 特定のパッケージのみ
go test ./internal/repository// repository/user_test.go
func TestUserRepository_Create(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
repo := NewUserRepository(db)
user := &User{Name: "Test", Email: "test@example.com"}
err := repo.Create(user)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
}// repository/interface.go
type UserRepository interface {
Create(user *User) error
}$ go test ./repository
# undefined: NewUserRepository
# FAIL// repository/user.go
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(user *User) error {
return r.db.Create(user).Error
}$ go test ./repository
# PASS- データベースは実DB(テスト用DB)を使用
- プロセス内処理(リポジトリ、サービス層)はモックしない
- Gin API はクリーンアーキテクチャ(domain/usecase/interface/infrastructure)で分離する
- Gin ハンドラーではバリデーション・変換・レスポンス整形に責務を限定する
- クエリは可読性・保守性・実行計画を優先する: 1クエリに過度に詰め込んで複雑化する場合は、責務ごとにクエリを分割してよい
- アプリケーション側の後処理は限定的に行う: SQL で不自然になる場合、または分割した方が全体の責務が明確になる場合に限る
- 1回のSQLで効率よく取得・判定できる処理はSQLで完結させる: アプリ側の分岐や後処理で分解しない
- Go + GORM では生クエリを優先的に使う: 複雑処理は
Raw/Execで実行し、ORMチェインに無理に寄せない - データベースは各テストで独立した状態にする
- トランザクションやロールバックでデータをクリーンアップ
- テスト用の設定ファイルを分離(config.test.yml)
- 環境変数でテスト用DBを切り替え(TEST_DB_NAME など)
- インターフェースは外部API用に定義(内部実装のモック用ではない)
- モックは外部API等のプロセス外結合のみに限定
- DBモックやリポジトリモックをテストコードの外側に公開しない
- テストヘルパー関数を用意して重複を削減
t.Helper()を使ってヘルパー関数を明示
-
要件を理解した
-
テストケースを列挙した
-
_test.goファイルにテストを作成した -
インターフェースを定義した
-
go testで失敗を確認した(実装前) -
テストが要件を網羅している
-
テストヘルパー関数を用意した
-
コード内のコメント(行コメント・ブロックコメント・ドキュメンテーションコメント)はすべて日本語で記載する