Goでアプリケーションを書いていると、「テストが書きにくいな」と感じる場面に出くわすことがあります。データベースに直接アクセスする関数、外部APIを呼び出すハンドラ——こうしたコードは、テスト時に本物のDBやAPIが必要になってしまい、ユニットテストのハードルが一気に上がります。
この記事では、GoのインターフェースとDI(依存性注入)を使って、テストしやすい設計に変えていく方法を解説します。Go 1.22以降を前提にしていますが、基本的な考え方はそれ以前のバージョンでも同じです。
Goのインターフェースの基本—暗黙的実装の仕組み
他言語とは異なる「暗黙的」な実装
JavaやC#では、インターフェースを実装するクラスにimplementsキーワードを書きます。Goにはこのキーワードがありません。構造体がインターフェースに定義されたメソッドをすべて持っていれば、自動的にそのインターフェースを満たすという仕組みです。
// インターフェースの定義
type Greeter interface {
Greet(name string) string
}
// 構造体の定義(implementsキーワードは不要)
type JapaneseGreeter struct{}
func (g *JapaneseGreeter) Greet(name string) string {
return "こんにちは、" + name + "さん"
}
func main() {
var g Greeter = &JapaneseGreeter{}
fmt.Println(g.Greet("田中"))
}
こんにちは、田中さん
この暗黙的な実装のおかげで、既存のコードを変更せずに新しいインターフェースを後から追加できます。これはDI設計との相性が非常に良い特徴です。
小さなインターフェースを定義する原則
Go標準ライブラリを見ると、インターフェースのメソッド数が非常に少ないことに気づきます。io.Readerはメソッドが1つだけ、io.ReadWriterでも2つです。
// 標準ライブラリの io.Reader(メソッド1つだけ)
type Reader interface {
Read(p []byte) (n int, err error)
}
// 標準ライブラリの io.Writer(メソッド1つだけ)
type Writer interface {
Write(p []byte) (n int, err error)
}
公式のGo Proverbsでも「The bigger the interface, the weaker the abstraction(インターフェースが大きいほど抽象化は弱くなる)」と言われています。インターフェースは1〜3メソッド程度に抑えるのが、Goらしい設計の基本です。
DIとは何か—依存性注入の基本的な考え方
DIの核心はシンプル
DI(Dependency Injection)という名前は仰々しく聞こえますが、やっていることは単純です。関数や構造体が必要とする依存を、外部から渡す——これだけです。
「依存」とは、たとえばデータベース接続、外部APIクライアント、ロガーなど、そのコードが動くために必要な外部のオブジェクトのことです。
コンストラクタインジェクションの実装
GoではNew関数(コンストラクタ)の引数として依存を渡すパターンが一般的です。
type UserService struct {
repo UserRepository
}
// コンストラクタで依存を注入
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(id string) (*User, error) {
return s.repo.FindByID(id)
}
UserServiceはUserRepositoryインターフェースに依存する
(具体的な実装ではなく、インターフェースに依存するのがポイント)
この形にしておくと、テスト時にモック実装を渡すだけでDB接続なしにテストできるようになります。
アンチパターン—テストできないコードの典型例
グローバル変数に依存するコード
まずはよくあるNGパターンから見てみます。以下のコードは動きますが、テストが困難です。
// アンチパターン:グローバル変数でDB接続を保持
var db *sql.DB
func init() {
var err error
db, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
}
func GetUserByID(id string) (*User, error) {
row := db.QueryRow("SELECT id, name, email FROM users WHERE id = $1", id)
var u User
err := row.Scan(&u.ID, &u.Name, &u.Email)
return &u, err
}
このコードの問題点は明確です。
GetUserByIDをテストするには本物のPostgreSQLが必要init()でDATABASE_URL環境変数がないとプログラム自体が起動しない- テストごとにDBの状態をリセットする手間が発生する
- 並列テストでデータが干渉する
具象型を直接参照するコード
グローバル変数は使っていなくても、具象型を直接参照していると同じ問題が起きます。
// アンチパターン:具象型に直接依存
type UserService struct {
db *sql.DB // 具象型(*sql.DB)に依存している
}
func (s *UserService) GetUser(id string) (*User, error) {
row := s.db.QueryRow("SELECT id, name, email FROM users WHERE id = $1", id)
var u User
err := row.Scan(&u.ID, &u.Name, &u.Email)
return &u, err
}
この場合、UserServiceのテストには*sql.DBのインスタンスが必要で、結局データベースなしではテストできません。ここにインターフェースを挟むのが、次のセクションで解説するリポジトリパターンです。
インターフェースを使ったリポジトリパターンの実装
リポジトリインターフェースの定義
データアクセス層をインターフェースとして定義し、ビジネスロジック層はインターフェースにのみ依存させます。
// domain/user.go
package domain
type User struct {
ID string
Name string
Email string
}
// UserRepository はユーザーデータへのアクセスを抽象化する
type UserRepository interface {
FindByID(id string) (*User, error)
Save(user *User) error
}
メソッドは2つだけ — 必要最小限に保つ
本番実装とモック実装
本番用のPostgreSQL実装を作ります。
// infra/postgres_user_repo.go
package infra
import (
"database/sql"
"myapp/domain"
)
type PostgresUserRepo struct {
db *sql.DB
}
func NewPostgresUserRepo(db *sql.DB) *PostgresUserRepo {
return &PostgresUserRepo{db: db}
}
func (r *PostgresUserRepo) FindByID(id string) (*domain.User, error) {
row := r.db.QueryRow(
"SELECT id, name, email FROM users WHERE id = $1", id,
)
var u domain.User
err := row.Scan(&u.ID, &u.Name, &u.Email)
if err != nil {
return nil, err
}
return &u, nil
}
func (r *PostgresUserRepo) Save(user *domain.User) error {
_, err := r.db.Exec(
"INSERT INTO users (id, name, email) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET name = $2, email = $3",
user.ID, user.Name, user.Email,
)
return err
}
テスト用のモック実装も簡単に作れます。
// domain/mock_user_repo_test.go
package domain_test
import "myapp/domain"
type MockUserRepo struct {
Users map[string]*domain.User
Err error
}
func (m *MockUserRepo) FindByID(id string) (*domain.User, error) {
if m.Err != nil {
return nil, m.Err
}
u, ok := m.Users[id]
if !ok {
return nil, fmt.Errorf("user not found: %s", id)
}
return u, nil
}
func (m *MockUserRepo) Save(user *domain.User) error {
if m.Err != nil {
return m.Err
}
m.Users[user.ID] = user
return nil
}
モック実装はmapで済む — DBもネットワークも不要
テストでDIの真価を発揮する
モックを使ったユニットテスト
DIで設計したコードは、テスト時にモックを注入するだけで済みます。
// service/user_service.go
package service
import "myapp/domain"
type UserService struct {
repo domain.UserRepository
}
func NewUserService(repo domain.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(id string) (*domain.User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
return nil, fmt.Errorf("ユーザー取得失敗: %w", err)
}
return user, nil
}
// service/user_service_test.go
func TestGetUser_Success(t *testing.T) {
mock := &MockUserRepo{
Users: map[string]*domain.User{
"u-1": {ID: "u-1", Name: "田中", Email: "tanaka@example.com"},
},
}
svc := service.NewUserService(mock)
user, err := svc.GetUser("u-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "田中" {
t.Errorf("got %s, want 田中", user.Name)
}
}
func TestGetUser_NotFound(t *testing.T) {
mock := &MockUserRepo{
Users: map[string]*domain.User{},
}
svc := service.NewUserService(mock)
_, err := svc.GetUser("unknown")
if err == nil {
t.Fatal("expected error, got nil")
}
}
$ go test ./service/ -v
=== RUN TestGetUser_Success
--- PASS: TestGetUser_Success (0.00s)
=== RUN TestGetUser_NotFound
--- PASS: TestGetUser_NotFound (0.00s)
PASS
ok myapp/service 0.002s
DB接続ゼロ、実行時間0.002秒。これがDIの真価です。テストの書き方についてはGoテストの書き方—testing・httptest・mockを正しく使い分けるでさらに詳しく解説しています。
テーブル駆動テストとの組み合わせ
Goのテーブル駆動テストとDIは非常に相性が良いです。テストケースごとに異なるモックの振る舞いを設定できます。
func TestGetUser_TableDriven(t *testing.T) {
tests := []struct {
name string
userID string
mock *MockUserRepo
want string
wantErr bool
}{
{
name: "正常系:ユーザーが見つかる",
userID: "u-1",
mock: &MockUserRepo{
Users: map[string]*domain.User{
"u-1": {ID: "u-1", Name: "田中"},
},
},
want: "田中",
wantErr: false,
},
{
name: "異常系:ユーザーが存在しない",
userID: "unknown",
mock: &MockUserRepo{Users: map[string]*domain.User{}},
wantErr: true,
},
{
name: "異常系:リポジトリがエラーを返す",
userID: "u-1",
mock: &MockUserRepo{Err: fmt.Errorf("db connection lost")},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := service.NewUserService(tt.mock)
user, err := svc.GetUser(tt.userID)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && user.Name != tt.want {
t.Errorf("got %s, want %s", user.Name, tt.want)
}
})
}
}
$ go test ./service/ -v -run TestGetUser_TableDriven
=== RUN TestGetUser_TableDriven
=== RUN TestGetUser_TableDriven/正常系:ユーザーが見つかる
=== RUN TestGetUser_TableDriven/異常系:ユーザーが存在しない
=== RUN TestGetUser_TableDriven/異常系:リポジトリがエラーを返す
--- PASS: TestGetUser_TableDriven (0.00s)
PASS
テストケースを追加するのがテーブルに行を足すだけなので、カバレッジを上げやすいのも利点です。
実践—HTTPハンドラでDIを組み立てる
main.goでの依存関係の配線
実際のアプリケーションでは、main.goで具体的な実装を生成し、DIで注入していきます。
// main.go
package main
import (
"database/sql"
"log"
"net/http"
_ "github.com/lib/pq"
"myapp/handler"
"myapp/infra"
"myapp/service"
)
func main() {
// 依存の生成
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
// DIで組み立て
userRepo := infra.NewPostgresUserRepo(db)
userSvc := service.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userSvc)
// ルーティング
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", userHandler.GetUser)
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
依存の流れ: main → handler → service → repository
具体的な実装を知っているのはmain.goだけ
main.goが「配線役」に徹し、各層はインターフェースにのみ依存する。この構造にしておけば、どの層もDBなしでテストできます。エラーの伝搬方法についてはGoのエラーハンドリングで失敗しない方法も参考にしてみてください。
コンストラクタ注入とメソッド注入の使い分け
DIのパターンは大きく2種類あります。どちらを使うかは依存のスコープで決まります。
| 項目 | コンストラクタ注入 | メソッド注入 |
|---|---|---|
| 注入タイミング | 構造体の生成時 | メソッドの呼び出し時 |
| 依存のスコープ | 構造体のライフタイム全体 | そのメソッド呼び出しのみ |
| 適したケース | DB接続、ロガーなど常に必要な依存 | リクエストごとに変わる依存(context等) |
| コード例 | NewService(repo) | svc.Do(ctx, logger) |
| テストのしやすさ | 構造体を一度作れば何度もテスト可能 | 呼び出しごとに渡す必要がある |
基本はコンストラクタ注入を使い、リクエストスコープの依存だけメソッド引数で渡すという方針がシンプルです。GoではDIコンテナ(Google WireやUber Digなど)を使うこともできますが、小〜中規模のプロジェクトなら手動配線で十分です。DIコンテナを導入するのは、依存の数が20を超えてmain.goが見づらくなってきたあたりが判断の目安になります。
まとめ
GoのインターフェースとDIパターンのポイントを振り返ります。
- Goのインターフェースは暗黙的実装で、
implementsキーワードが不要 - インターフェースは1〜3メソッド程度の小さな粒度で定義する
- DIの基本はコンストラクタで依存を外部から渡すこと
- グローバル変数や具象型への直接依存はテスタビリティを下げるアンチパターン
- リポジトリパターンでデータアクセス層を抽象化すると、DBなしでテストが可能になる
main.goを配線役に徹させ、具体的な実装を知るのはmain.goだけにする- コンストラクタ注入をベースに、リクエストスコープの依存だけメソッド引数で渡す
よくある質問(FAQ)
Q. GoでDIコンテナは使うべき?
小〜中規模のプロジェクトであれば、手動配線で十分です。main.goでNew関数を順番に呼ぶだけなので、コードも読みやすく保てます。依存の数が増えてmain.goが肥大化してきたら、Google WireやUber Digの導入を検討するタイミングです。ただし、DIコンテナはリフレクションやコード生成に依存するため、導入コストとのトレードオフは意識した方が良いです。
Q. インターフェースはどこに定義するのが正解?
Goのベストプラクティスとしては、インターフェースを使う側のパッケージに定義するのが推奨されています。たとえばUserRepositoryインターフェースは、infraパッケージ(実装側)ではなく、domainやserviceパッケージ(利用側)に置きます。これにより、実装パッケージへの依存が逆転し、疎結合な設計が実現できます。
Q. モック生成ツールは使った方がいい?
インターフェースのメソッド数が少ない(1〜3個)うちは、手動でモックを書く方がシンプルで理解しやすいです。メソッドが増えてきたり、呼び出し回数の検証が必要な場合は、gomockやmoqといったツールが便利です。ただし、そもそもインターフェースが大きくなっている時点で、インターフェースの分割を先に検討するのが良いかもしれません。

