はじめに
Goで開発を続けていると、テストが増えるにつれて「どこに何を書くか」が曖昧になりやすくなります。
特に単体テスト、DBを使う結合テスト、業務フローを見るシナリオテストを同じ場所で扱うと、実行時間や保守性の面で負担が出やすくなります。
1つの実装ファイルに対して、テストを3種類に分ける運用が実務では扱いやすいです。
- シナリオテスト
- 結合テスト(DBを含む)
- 純粋な単体テスト
この記事では、この3分割を前提にした構成を整理します。
実際に動かせるサンプルコードは、次のリポジトリで公開しています。
先に結論
user_service.go のような実装に対して、次の3ファイルを用意します。
user_service_unit_test.gouser_service_integration_test.gouser_service_scenario_test.go
この形にすると、失敗時の切り分けが速くなります。
どのレイヤーで壊れたかをファイル名だけで判断できるからです。
重いテストと軽いテストを分けて実行しやすくなる点も、日々の開発では大きな利点です。
なぜ3分割が良いのか
テストを1ファイルに混ぜると、次の問題が起きやすくなります。
- 遅いテストが混ざってローカル実行が重くなる
- 失敗時に原因の境界が曖昧になる
- 変更時に不要なケースまで読み直すことになる
3分割にすると、責務が明確になります。
- 単体テストはロジックの正しさ
- 結合テストはDBとの整合性
- シナリオテストは業務フロー全体
3分割に対する懸念について
この構成を提案すると、「実装とテストは1対1でまとめたほうが良いのでは」という意見が出ることがあります。
この指摘には、いくつか合理的な背景があります。
まず、Goではテストの実行単位はファイルではなくパッケージです。
そのため、ファイルを分けても実行境界が自動で分離されるわけではありません。
分離はあくまで整理のための構造であり、仕組みとしての強制力は持ちません。
また、テストを細かく分けすぎると、かえって探索コストが上がる場合もあります。
実装変更時に「どのファイルまで確認すべきか」が分散し、慣れていないメンバーには追いづらくなる可能性もあります。
さらに、重いテストと軽いテストの分離は、ファイル構成ではなく -short や build tags といった実行オプションで制御すべきだという考え方もあります。
この立場では、構造よりも実行戦略を重視します。
これらは十分に理解できる懸念です。
それでも3分割を選ぶ理由
そのうえで、本記事で提案している3分割は「Goの正解」ではなく、あくまで運用上の最適化です。
目的は次の2点に絞られます。
- 失敗時に原因境界を即座に切り分けること
- 重いテストと軽いテストの存在を視覚的に明確にすること
ファイル名で責務を明示することで、
- まず単体を見る
- DB絡みはここを見る
- 業務フローはここを見る
といった判断が速くなります。
これは言語仕様の問題ではなく、チームの運用設計の問題です。
他パターンとの比較
実際のチームでは、次のような構成もよく見かけます。
- 1つの
*_test.goに全テストをまとめる -
unit/integration/のようにディレクトリで分ける
1ファイルにまとめる方式は着手しやすい反面、件数が増えるほど読みづらくなり、重いテストが混ざって実行時間の見通しも悪くなります。
ディレクトリ分割は整理しやすいですが、実装ファイルとの対応が離れやすく、変更時に追う範囲が広がりがちです。
user_service_*.go のように実装ファイル名を軸にテスト種別だけ分けると、実装との対応を保ったまま責務も分離できます。
Goの *_test.go 文化とも相性が良く、保守時に迷いにくい構成です。
3分割を検討する目安
「いつから分けるか」を先に決めておくと、ルールが形骸化しにくくなります。
次のどれかに当てはまるなら、3分割を検討する価値があります。
- 1実装ファイルに対するテストが10件を超え、レビュー時の読み直しが重い
-
go test ./...がローカルで60秒を超え、日常的に待ち時間が発生する - 障害やCI失敗時に、単体・DB・業務フローのどこが原因か切り分けに時間がかかる
逆に、件数が少なく全体を短時間で把握できる段階では無理に分ける必要はありません。
3分割しないほうが良い境界条件
3分割は有効ですが、常に正解ではありません。次のような場面では、まずは分けすぎないほうが運用しやすいです。
- テストケース自体が少なく、1ファイルでも見通しが十分に良い
- 小規模パッケージで、変更範囲と影響範囲を追いやすい
- DB依存がなく、結合テストを分離する実益が小さい
- E2Eやシナリオ実行の基盤が未整備で、運用だけ先に複雑になる
目安としては、最初は *_unit_test.go 中心で始め、DB依存や業務フロー検証が増えた段階で integration と scenario を段階的に分ける進め方が安全です。
各テストの役割
純粋な単体テスト
外部依存を持たず、最速で回すテストです。
分岐や計算、バリデーションなどロジックの中心をここで担保します。
結合テスト(DBを含む)
Repository層やトランザクション、SQLの結果を確認します。
単体では拾えない、DBスキーマやクエリ由来の問題を検出します。
シナリオテスト
ユースケース単位で入口から出口まで確認します。
件数を増やしすぎず、重要フローに絞るのが運用しやすいです。
命名と配置のルール
- 実装ファイル名を接頭辞にする
- 末尾でテスト種別を明示する
- 迷う名前を使わない
例
user_service.gouser_service_unit_test.gouser_service_integration_test.gouser_service_scenario_test.go
この対応を統一しておくと、実装変更時に見るべきテストがすぐ決まります。
実行方針とコマンド例
- 開発中は単体テストを常時実行
- PRでは単体+結合テストを実行
- シナリオテストは重要変更時または定期実行
この運用だと、速度と検出力のバランスを取りやすくなります。
例
- 全体実行:
go test ./... - 単体中心で実行:
go test ./... -run 'TestUserService_Unit' - 結合中心で実行:
go test ./... -run 'TestUserService_Integration'
-run は分かりやすい反面、テスト名の命名規則に強く依存します。
チーム運用を安定させるなら、-short や build tags も併用できる形にしておくと安全です。
-short を使う例
- 開発中(重いテストを除外):
go test ./... -short - CI全体:
go test ./...
build tags を使う例
- 単体中心:
go test ./... - 結合テスト込み:
go test ./... -tags=integration
結合テスト側には次のようにタグを付けます。
//go:build integration
// +build integration
Makefile で回し分けるなら、次のように入口を固定しておくと運用が安定します。
test:
go test ./...
test-unit:
go test ./... -short
test-integration:
go test ./... -tags=integration
ファイル名で視覚的に管理しつつ、実行時は -short やタグで切り分けると、命名変更の影響を受けにくくなります。
最小構成のコード例
ここでは骨組みだけ示します。
// user_service.go
package user
import "context"
type UserService struct {
repo UserRepository
}
type UserRepository interface {
Exists(ctx context.Context, id string) (bool, error)
}
func (s UserService) CanLogin(ctx context.Context, id string) (bool, error) {
return s.repo.Exists(ctx, id)
}
// user_service_unit_test.go
package user
import (
"context"
"testing"
)
type stubRepo struct {
exists bool
err error
}
func (r stubRepo) Exists(context.Context, string) (bool, error) {
return r.exists, r.err
}
func TestUserService_Unit_CanLogin(t *testing.T) {
svc := UserService{repo: stubRepo{exists: true}}
ok, err := svc.CanLogin(context.Background(), "u1")
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
}
// user_service_integration_test.go
package user
import "testing"
func TestUserService_Integration_CanLoginWithDB(t *testing.T) {
// テストDBを起動し、Repository実装を挿して確認する
t.Skip("example")
}
// user_service_scenario_test.go
package user
import "testing"
func TestUserService_Scenario_LoginFlow(t *testing.T) {
// APIまたはUseCase入口からログイン完了までを確認する
t.Skip("example")
}
移行手順
- 変更頻度が高い実装ファイルから着手する
- 既存テストを3種類へ振り分ける
- ファイル名を
*_unit_test.goなどに統一する - CIの実行ジョブを3種類に合わせて整理する
一度に全体を直すより、変更が入る箇所から段階的に移行するのが現実的です。
まとめ
3分割は絶対的な正解ではありません。
しかし、
- 原因境界が明確になる
- 実行戦略を設計しやすい
- 保守時の迷いが減る
という運用上のメリットがあります。
重要なのは「分けること」ではなく、
- 役割が明確か
- 実行時間が健全か
- 切り分けが速いか
です。
この条件を満たせるなら、構成はチームに合わせて選べばよい、という立場です。