モックと依存注入のレビュー:過剰抽象か適切分離かをどう判断するか

Go言語では、テスト容易性を高めるために interface による抽象とモック導入がよく行われます。
しかし設計レビューでは、「将来的な柔軟性」を根拠にした抽象が本当に必要かどうかを見極めることが重要です。
この記事では、まず技術的な構造を整理した上で、レビューアーが設計判断を行う上での観点を明確にします。


1. モックと依存注入(DI)の基本

モックとDIとは
  • モック:外部依存の動作を模倣するスタブ的実装。主にテスト用途。
  • DI(依存注入):構造体の中で依存を直接生成せず、外部から注入する手法。依存の抽象化と責務の分離を目的とする。

目的としては以下の3つがよく挙げられます:

  • 単体テストの実行を外部環境に依存させない
  • 実装の差し替え・将来的な拡張を容易にする
  • 呼び出し側の責務を単純化する

2. 基本的なモック構造とDIの実装

type MailSender interface {
    SendMail(to, subject, body string) error
}

type RealMailSender struct{}

func (r *RealMailSender) SendMail(to, subject, body string) error {
    // SMTP送信処理
    return nil
}

type UserService struct {
    sender MailSender
}

func (s *UserService) NotifyUser(email string) error {
    return s.sender.SendMail(email, "Hello", "Welcome!")
}
@Reviewer
`UserService`が`MailSender`に依存している設計は妥当ですが、実装が1種類しか存在しない場合はinterface抽象が形骸化していないかを検討してください。テスト目的での抽象であれば、その意図が明文化されているとより良いです。
評価ポイント
  • UserServiceMailSender の実装詳細に依存しておらず、抽象に依存している
  • テスト時には MailSender を差し替えることで、 NotifyUser() 単体の検証が可能

3. 抽象のやりすぎによる過剰設計

type TimeProvider interface {
    Now() time.Time
}
@Reviewer
`time.Now()` は副作用や状態変化が少ない標準関数のため、抽象を挟む必然性が薄いです。変更可能性がない依存を抽象化すると、逆に可読性と保守性が下がります。
抽象の過剰による問題
  • time.Now() はGoの標準関数で十分に安定しており、変更頻度は限りなく低い
  • テストのためだけにinterface化すると、読解負荷・設計冗長性が増す
  • ラップの存在意義や理由が初見で伝わらず、意図を説明する責務が発生する

4. 適切な抽象と過剰抽象の構造比較

UML Diagram
設計上の指摘視点
  • テスト対象である UserServiceMailSender の具体実装を知らずに動作する
  • 将来 SendMail() の処理がAPI送信などに切り替わっても、 UserService 側は変更不要
  • 一方で、未来に変わる予定がない機能でまでこのような抽象を使うと、逆に保守性を損なう

5. 抽象の妥当性を見抜くための観点

観点 評価基準
責務分離 インターフェース化によって呼び出し元の責務が簡潔になっているか
差し替え前提 将来的に条件や環境によって切り替える設計前提があるか
テスト効果 モックによって単体テストの粒度が明確になっているか
過剰抽象 実装が一種類しか存在しないのにinterfaceを使っていないか
DIの実装方法 初期化時に明示的に注入されているか(曖昧なグローバル化されていないか)

6. モックの実装と実用的なテスト構造

type MockMailSender struct {
    Sent bool
}

func (m *MockMailSender) SendMail(to, subject, body string) error {
    m.Sent = true
    return nil
}

func TestNotifyUser(t *testing.T) {
    mock := &MockMailSender{}
    service := UserService{sender: mock}

    _ = service.NotifyUser("[email protected]")

    if !mock.Sent {
        t.Errorf("メール送信が呼び出されていません")
    }
}
@Reviewer
モックを導入した目的(副作用の有無の検証)が明確で、効果的な構造になっています。テストコードの中で、他の副作用や実行順の検証も必要なら補足を検討してください。
モックの意味を見失わないために
  • モックは「副作用が発生したか(呼び出されたか)」を検証するための手段
  • 何も起きない関数、戻り値しか存在しない関数のモックは意味を持たないケースが多い
  • モック導入の労力に対して、テスト網羅性・価値が見合っているかを冷静に評価する

7. 典型的な過剰抽象の削減例

- type UUIDProvider interface {
- NewUUID() string
- }
+ // uuid.New().String() を直接使用
@Reviewer
UUIDのように将来的な変更が想定しにくい処理に対しては、インターフェースを介さず直接呼び出す方が簡潔でわかりやすいです。抽象化の理由が説明できない場合は見直しを検討してください。
指摘の理由
  • UUIDのような静的な標準生成関数に対して抽象を挟むのは意味が薄い
  • 「将来的に変わる見込みがあるか?」という問いにYesと答えられない抽象は原則避ける

8. まとめ:抽象化の「未来」を説明できるか

テスト容易性は重要な設計指針ですが、
「テストのために抽象を入れました」という構文都合だけの設計では、後続開発者の混乱を招きます。

レビューアーとしては以下の問いを設計者に投げかけたい:

  • このinterfaceは未来に何を見据えて導入されたか?
  • その依存は将来的に変わる余地が本当にあるのか?
  • 設計が“柔軟性のための柔軟性”になっていないか?

抽象化の目的が明確に説明され、かつそれが実際のプロダクト進化に貢献しうると判断できるときにのみ、
interface化やDIによる抽象は価値を持ちます。