ORMを使ったテストコードをどう書くべきか、悩んだことはないだろうか。
本記事ではGo + GORM + PostgreSQLの構成で、テストのスピードと正確性を両立する戦略について紹介する。
プロジェクト構成
今回のプロジェクト構成は以下の通りである。
- ORM: GORM
- マイグレーション: Goose
- DB: PostgreSQL
- 目的: Webメディアサービス開発
テストで重視したいこと
テストには相反する要求がある。
- スピード: CIの実行時間を短くしたい
- 正確性: 本番環境に近い環境でテストしたい
- コスト: 複雑な環境構築は避けたい
これらをすべて満たすのは難しいが、使い分けることで両立できる。
テストツールの候補
検討したツールは以下の4つである。
1. go-sqlmock
SQLのモック化ライブラリである。
メリット:
- 超高速(DB不要)
- 環境構築不要
- 発行されるクエリの検証が可能
デメリット:
- 実際のDBを動かしていない
mock.ExpectQuery(
"SELECT * FROM users WHERE id = $1",
).WithArgs(1).WillReturnRows(
sqlmock.NewRows([]string{"id", "name"}).
AddRow(1, "Alice"),
)
// テスト実行
user, err := repo.GetUser(ctx, 1)
2. testcontainer
Go製のDockerコンテナ管理ライブラリである。テスト実行時に自動でPostgresコンテナを起動できる。
メリット:
- 本番環境と同じDBでテスト可能
デメリット:
- Docker環境が必須
- コンテナの起動に時間がかかる(数秒)
- CIでの実行コストが高い
postgres, err := testcontainers.GenericContainer(ctx,
testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:15",
ExposedPorts: []string{"5432/tcp"},
},
Started: true,
})
3. Docker Compose
docker-compose.ymlでPostgresを起動し、開発者が手動でコンテナ管理する。
メリット:
- 本番環境と同じDBでテスト可能
デメリット:
- Docker環境が必須
- CIでのセットアップが煩雑
services:
postgres:
image: postgres:15
ports:
- "5432:5432"
environment:
POSTGRES_DB: testdb
POSTGRES_USER: test
4. SQLite (インメモリ)
インメモリDBで超高速である。ORMで抽象化されているから使えるかもしれない。
メリット:
- 超高速(インメモリ)
- 環境構築不要
デメリット:
- PostgreSQLとSQLiteは別物
- Gooseのマイグレーションが使えない
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
db.AutoMigrate(&User{}, &Article{})
本番環境との差異が大きすぎるため却下した。
ツール比較表
| ツール | スピード | 正確性 | Docker要否 | CI適合 |
|---|---|---|---|---|
| go-sqlmock | ○ | △ | 不要 | ○ |
| testcontainer | △ | ○ | 必要 | △ |
| Docker Compose | △ | ○ | 必要 | △ |
| SQLite | ○ | △ | 不要 | ○ |
採用した戦略
単体テストと結合テストで使い分けることにした。
- 単体テスト: スピード重視 → go-sqlmock
- 結合テスト: 正確性重視 → Docker + Postgres
単体テスト: go-sqlmock
採用の根拠は以下の通りである。
- 実行速度が最優先
- CI時間を短縮したい
- ビジネスロジックのテストに集中
- 外部制約のためにセットアップするコストを排除
対象:
- リポジトリ層
- ビジネスロジック
- 頻繁に実行するテスト
実行頻度:
- 毎回のコミット
- Pull Request時
実行時間は1秒以内である。
結合テスト: Docker + Gooseマイグレーション
採用の根拠は以下の通りである。
- 本番環境と同等の状態でテスト
- マイグレーションも含めてテスト
- リレーションなど制約の動作を保証
対象:
- E2Eフロー
- マイグレーション検証
- リレーションを含むSQL
実行頻度:
- main / release/* branch へのmerge時
- デプロイ前
実行時間は数秒〜数十秒である。
実装例
go-sqlmock
func TestGetUser(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
gormDB, err := gorm.Open(postgres.New(
postgres.Config{Conn: db}), &gorm.Config{})
// 期待するクエリを定義
rows := sqlmock.NewRows([]string{"id", "name", "email"}).
AddRow(1, "Alice", "alice@example.com")
mock.ExpectQuery(`SELECT \* FROM "users"`).
WithArgs(1).
WillReturnRows(rows)
// テスト実行
repo := NewUserRepository(gormDB)
user, err := repo.GetUser(ctx, 1)
assert.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
}
結合テスト
//go:build integration
func TestE2E(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
require.NoError(t, err)
server := setupTestServer(t, db)
defer server.Close()
setupTestData(t, db)
defer cleanupTestData(t, db)
resp, err := http.Get(server.URL + "/users")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var user User
json.NewDecoder(resp.Body).Decode(&user)
assert.Equal(t, "Alice", user.Name)
}
実行方法は以下の通りである。
docker-compose up -d postgres
goose up
go test -tags=integration ./...
実際に使ってみた感想
良かった点:
- 単体テストが爆速で開発体験が良い
- 結合テストで本番の問題を早期発見
- テストの役割が明確で保守しやすい
- CIのコストが最小限
困った点:
- go-sqlmockのExpectは書くのが面倒
スピードと正確性、両方のテストを使い分けることで開発体験とテストの品質を両立できた。
まとめ
テストもトレードオフである。スピードと正確性を使い分けることで、両方のメリットを享受できる。
| テスト種別 | ツール | 特徴 |
|---|---|---|
| 単体テスト | go-sqlmock | 高速・頻繁に実行 |
| 結合テスト | Docker + Postgres | 正確・重要時に実行 |
ぜひ参考にしてみなさんのプロジェクトに役立ててほしい。
参考