💊

Goテストで「同じモックを複数回呼ぶ/1回目成功→2回目エラー」をきっちり表現する

に公開

ループや再試行ロジックをテストするときに同じメソッドを複数回呼ぶ—「1回目は成功」「2回目はエラー」のように呼び出しごとに結果を切り替えるにはどう書く?

本稿は初学者でも迷わないよう、最小コードで“順序と回数”を保証する実装パターンを3通りで示します。


TL;DR

  • gomock 推奨InOrder で順序を、Times/DoAndReturn で回数と挙動を制御。順序制約はデフォルトでは無効なので InOrder/Call.After を使うのがコツ。Go Packages
  • testify/mockmock.InOrder(v1.10+)と Once/Times の組み合わせが簡潔で分かりやすい。Go Packages
  • 手書きフェイク:依存ゼロで最速。複雑な順序条件には不向き。

想定シナリオと題材コード

テスト対象は、配列の Item を順に保存する Process。2回目の保存で失敗したら処理全体をエラーにする仕様です。

package app

import (
    "context"
    "fmt"
)

type Item struct{ ID int }

type Repo interface {
    Save(ctx context.Context, it Item) error
}

func Process(ctx context.Context, r Repo, items []Item) error {
    for i, it := range items {
        if err := r.Save(ctx, it); err != nil {
            return fmt.Errorf("save %d failed: %w", i+1, err)
        }
    }
    return nil
}


1) gomock:順序+回数+戻り値の切替を“意図が伝わる”形で書く(おすすめ)

gomock は順序は既定では保証されません。InOrder か Call.After を使うと順序制約を付与できます。また Times、DoAndReturn で回数や戻り値を動的に制御。Go Packages

1-1. InOrder で「1回目成功→2回目エラー」の順序を宣言

package app_test

import (
    "context"
    "errors"
    "testing"

    "github.com/stretchr/testify/require"
    "go.uber.org/mock/gomock"

    // mockgen で生成したモックを想定
    //   mockgen -source=repo.go -destination=repo_mock_test.go -package=app_test
    //   (go:generate に載せておくと便利)
)

func TestProcess_gomock_InOrder(t *testing.T) {
    t.Parallel()

    ctrl := gomock.NewController(t)
    t.Cleanup(ctrl.Finish)

    m := NewMockRepo(ctrl)
    items := []app.Item{{ID: 1}, {ID: 2}}

    gomock.InOrder(
        m.EXPECT().Save(gomock.Any(), items[0]).Return(nil),
        m.EXPECT().Save(gomock.Any(), items[1]).Return(errors.New("boom")),
    )

    err := app.Process(context.Background(), m, items)
    require.Error(t, err)
    require.ErrorContains(t, err, "boom")
}

  • InOrder呼び出し順の制約を付与(Call.After でも可。InOrder のほうが読みやすい)。Go Packages

1-2. Times + DoAndReturn で“同じ期待”に2回ぶんの挙動をまとめる

func TestProcess_gomock_DoAndReturn(t *testing.T) {
    t.Parallel()

    ctrl := gomock.NewController(t)
    t.Cleanup(ctrl.Finish)

    m := NewMockRepo(ctrl)
    items := []app.Item{{ID: 1}, {ID: 2}}

    call := 0
    m.EXPECT().
        Save(gomock.Any(), gomock.Any()).
        Times(2). // ← ちょうど2回
        DoAndReturn(func(context.Context, app.Item) error {
            call++
            if call == 1 {
                return nil // 1回目
            }
            return errors.New("boom") // 2回目
        })

    err := app.Process(context.Background(), m, items)
    require.Error(t, err)
}

  • DoAndReturn はコール時に実行される関数で戻り値を決められます。Times(n) と組み合わせると**「n回だけこの挙動」**が表現できます。Go Packages

💡 どちらを使う?

  • 明示的に順序を縛りたいInOrder
  • “同型の呼び出し”を回数でまとめて扱いたい → Times + DoAndReturn

2) testify/mock:mock.InOrder(v1.10+)+ Once/Times の定石

testify/mock には mock.InOrder があり、並べた Call の順序を保証できます。個々の呼び出しは Once / Times で回数を定義し、最後に AssertExpectations で期待と実績の整合を検証します。Go Packages

package app_test

import (
    "context"
    "errors"
    "testing"

    "github.com/stretchr/testify/mock"
    "github.com/stretchr/testify/require"
)

type RepoMock struct{ mock.Mock }

func (m *RepoMock) Save(ctx context.Context, it app.Item) error {
    args := m.Called(ctx, it)
    return args.Error(0)
}

func TestProcess_testify_InOrder(t *testing.T) {
    t.Parallel()

    m := new(RepoMock)
    items := []app.Item{{ID: 1}, {ID: 2}}

    c1 := m.On("Save", mock.Anything, items[0]).Return(nil).Once()
    c2 := m.On("Save", mock.Anything, items[1]).Return(errors.New("boom")).Once()
    mock.InOrder(c1, c2) // ← 順序保証(v1.10+)

    err := app.Process(context.Background(), m, items)
    require.Error(t, err)

    m.AssertExpectations(t) // 回数・未消化の期待を確認
}

  • mock.InOrderv1.10.0 で追加。Once/Times などの回数指定 API と併用します。Go Packages

3) 依存ゼロの「手書きフェイク」:小規模には最速

外部ライブラリなしで、状態を持つフェイクを1つ置くだけでも目的は達せられます。

type FakeRepo struct{ calls int }

func (f *FakeRepo) Save(ctx context.Context, it app.Item) error {
    f.calls++
    if f.calls == 1 {
        return nil
    }
    return errors.New("boom")
}

func TestProcess_fake(t *testing.T) {
    t.Parallel()
    f := &FakeRepo{}
    items := []app.Item{{1}, {2}}

    err := app.Process(context.Background(), f, items)
    require.Error(t, err)
}

Pros: 依存ゼロ/読みやすい

Cons: メソッドが増える・順序条件が複雑になるほどメンテがつらくなる → gomock/testify に移行推奨


よくある落とし穴(と回避策)

  • 順序がすり抜ける
    • gomock は既定で順序を強制しないため、必要なら InOrderCall.After を使います。Go Packages
    • testify/mock は mock.InOrder(v1.10+)を使い、Once/Times と組み合わせます。Go Packages
  • 回数がずれる
    • gomock:Times(n) / AnyTimes() を正しく指定。Finish()defert.Cleanup で必ず呼ぶ。Go Packages
    • testify/mock:最後に AssertExpectations(t)未実行の期待を検出。Go Packages
  • 引数比較でコケる
    • gomock:gomock.Any() / AssignableToTypeOf などのマッチャを使う。Go Packages
    • testify/mock:mock.Anything / MatchedBy を活用。Go Packages

セットアップ一式(コピペ用)

gomock 導入とモック生成

go get go.uber.org/mock@latest
go install go.uber.org/mock/mockgen@latest
# 例: インターフェイスからモック生成
mockgen -source=repo.go -destination=repo_mock_test.go -package=app_test

  • InOrder / Call.After / DoAndReturn / Times は gomock 本体に含まれる API(go.uber.org/mock/gomock)。Go Packages
  • Uber によるフォークでメンテ継続中(背景)。GitHub

testify/mock の導入

go get github.com/stretchr/testify@latest

  • mock.InOrder(v1.10+)、OnceTimesAssertExpectations を提供。Go Packages

使い分け指針(短評)

  • gomock を常用するチーム
    • InOrder で順序を、Times/DoAndReturn で回数・動的戻り値を制御。意図が一目で伝わるGo Packages
  • testify を既に使っている
    • mock.InOrder + Once/Times で十分シンプル。require/assert 群とも親和性◎。Go Packages
  • 最小依存で済ませたい小テスト
    • 手書きフェイクで OK(増えてきたらモックへ移行)。

付録:多段シナリオのヒント

  • A→B→A のようなクロス順序
    • gomock なら Call.After を組み合わせて任意依存関係を作れます。InOrder より柔軟。Go Packages
  • 3回以上で段階的に結果を変える
    • gomock:Times(3)DoAndReturn 内でカウンタ分岐。Go Packages
    • testify:On(...).Return(...).Times(n) を並べるか、Run/MatchedBy を併用。Go Packages

参考資料

  • gomock(go.uber.org/mock/gomock)公式ドキュメント:InOrder / Call.After / DoAndReturn / Times の解説あり。Go Packages
  • testify/mock 公式ドキュメント:mock.InOrder(v1.10+)/ Once / Times / AssertExpectationsGo Packages
  • gomock(Uber フォーク)リポジトリの背景説明。GitHub

Discussion