GoのインターフェースとDI設計パターン—テスタブルなコードの作り方

GoのインターフェースとDI設計パターン—テスタブルなコードの作り方 | mohablog

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.goNew関数を順番に呼ぶだけなので、コードも読みやすく保てます。依存の数が増えてmain.goが肥大化してきたら、Google WireやUber Digの導入を検討するタイミングです。ただし、DIコンテナはリフレクションやコード生成に依存するため、導入コストとのトレードオフは意識した方が良いです。

Q. インターフェースはどこに定義するのが正解?

Goのベストプラクティスとしては、インターフェースを使う側のパッケージに定義するのが推奨されています。たとえばUserRepositoryインターフェースは、infraパッケージ(実装側)ではなく、domainserviceパッケージ(利用側)に置きます。これにより、実装パッケージへの依存が逆転し、疎結合な設計が実現できます。

Q. モック生成ツールは使った方がいい?

インターフェースのメソッド数が少ない(1〜3個)うちは、手動でモックを書く方がシンプルで理解しやすいです。メソッドが増えてきたり、呼び出し回数の検証が必要な場合は、gomockmoqといったツールが便利です。ただし、そもそもインターフェースが大きくなっている時点で、インターフェースの分割を先に検討するのが良いかもしれません。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次