16
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

弁護士ドットコムAdvent Calendar 2022

Day 7

Goでモックを作成してテストをする

Last updated at Posted at 2022-12-06

この記事は 弁護士ドットコム Advent Calendar 2022 の7日目の記事です。

はじめに

Go のテストにおいてインターフェースからモックを生成する gomock の基本的な使用からサンプルコードを使ってテストコードを書くところまでを説明します。

golang/mock とは

Go 公式が提供しているライブラリで、インターフェースの定義からモックを生成することができます。

  • mockgen … モックを自動生成するツール
  • gomock … 生成したモックを扱うパッケージ

インストール

README に記載されている通りにインストールしてください。
mockgen というCLIツールと gomock というパッケージの両方をインストールする必要があります。

  • mockgen (初回のみ)
    $ go install github.com/golang/mock/mockgen
    
  • gomock
    $ go get github.com/golang/mock/gomock
    

サンプルコード

サンプルとして、プレイヤー情報の一覧を外部のAPI から取得し、ランキング順に並び替えて返却する処理を作成します。
こちらの実装を gomock を使ってテストします。
※ 今回はインターフェース(外部のAPI)の実装は省略します。

.
├── model
│   └── player.go # プレイヤー情報のモデル
├── repository
│   └── player_api.go # プレイヤーAPIのインターフェースを定義
├── usecase
│   └── get_player_ranking.go # プレイヤー情報を取得し、ランキングを返す
└──go.mod

流れとしては、player_api.go からインターフェースのモックを作成し、get_player_ranking.go の実装をモックを使ってテストします。

下記は今回扱うプレイヤー情報のモデルです。

model/player.go
package model

type Player struct {
	ID      string
	Name    string
	Ranking int
}

gomockの基本的な扱い方

モックの生成

下記のインターフェースを元にモックを作成します。

repository/player_api.go
package repository

type PlayerAPIRepository interface {
	GetPlayerList(context.Context) ([]*model.Player, error)
}

下記のようなコマンドを実行することでモックが生成されます。

$ mockgen -source=player_api.go -destination=./mock/player_api_mock.go -package=repository
  • -source … モックを生成する対象のファイルを指定
  • -destination … 生成したモックを保存する場所を指定
  • -package … 生成するモックのパッケージ名を指定

コマンド実行後に自動でモックが生成されます。

├── repository
│   ├── mock
│   │   └── player_api_mock.go
│   └── player_api.go

下記は mockgen から自動生成されたソースコードです。
詳しくは後述しますので、読み飛ばしていただいて構いません。

repository/mock/player_api_mock.go
// Code generated by MockGen. DO NOT EDIT.
// Source: player_api.go

// Package repository is a generated GoMock package.
package repository

import (
	context "context"
	reflect "reflect"

	model "example.com/model"
	gomock "github.com/golang/mock/gomock"
)

// MockPlayerAPIRepository is a mock of PlayerAPIRepository interface.
type MockPlayerAPIRepository struct {
	ctrl     *gomock.Controller
	recorder *MockPlayerAPIRepositoryMockRecorder
}

// MockPlayerAPIRepositoryMockRecorder is the mock recorder for MockPlayerAPIRepository.
type MockPlayerAPIRepositoryMockRecorder struct {
	mock *MockPlayerAPIRepository
}

// NewMockPlayerAPIRepository creates a new mock instance.
func NewMockPlayerAPIRepository(ctrl *gomock.Controller) *MockPlayerAPIRepository {
	mock := &MockPlayerAPIRepository{ctrl: ctrl}
	mock.recorder = &MockPlayerAPIRepositoryMockRecorder{mock}
	return mock
}

// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockPlayerAPIRepository) EXPECT() *MockPlayerAPIRepositoryMockRecorder {
	return m.recorder
}

// GetPlayerList mocks base method.
func (m *MockPlayerAPIRepository) GetPlayerList(arg0 context.Context) ([]*model.Player, error) {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "GetPlayerList", arg0)
	ret0, _ := ret[0].([]*model.Player)
	ret1, _ := ret[1].(error)
	return ret0, ret1
}

// GetPlayerList indicates an expected call of GetPlayerList.
func (mr *MockPlayerAPIRepositoryMockRecorder) GetPlayerList(arg0 interface{}) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlayerList", reflect.TypeOf((*MockPlayerAPIRepository)(nil).GetPlayerList), arg0)
}

生成されたモックを見てみる

生成されたモックを見てみます。
まずモックのファイルには2つの構造体が定義されています。

// MockPlayerAPIRepository is a mock of PlayerAPIRepository interface.
type MockPlayerAPIRepository struct {
	ctrl     *gomock.Controller
	recorder *MockPlayerAPIRepositoryMockRecorder
}

// MockPlayerAPIRepositoryMockRecorder is the mock recorder for MockPlayerAPIRepository.
type MockPlayerAPIRepositoryMockRecorder struct {
	mock *MockPlayerAPIRepository
}
  • MockPlayerAPIRepository は、type PlayerAPIRepository interface を満たす構造体です
  • MockPlayerAPIRepositoryMockRecorder は、モックの呼び出しなどを管理するための構造体で、利用者側では特に意識する必要はないです
// NewMockPlayerAPIRepository creates a new mock instance.
func NewMockPlayerAPIRepository(ctrl *gomock.Controller) *MockPlayerAPIRepository {
	mock := &MockPlayerAPIRepository{ctrl: ctrl}
	mock.recorder = &MockPlayerAPIRepositoryMockRecorder{mock}
	return mock
}
  • NewMockPlayerAPIRepository は、*gomock.Controller からPlayerAPIRepository のモックを生成します
    • *gomock.Controller の生成は後述します
  • モックを使用する際は、こちらの関数を使用してください

モックを使ってテストを書いてみる

ここからは実際にサンプルコードの実装とテストを書いていきます。

下記は「プレイヤー情報の一覧を外部のAPI から取得し、ランキング順に並び替える」実装です。

usecase/get_player_ranking.go
package usecase

import (
	"context"
	"errors"
	"sort"

	"example.com/model"
	"example.com/repository"
)

type GetPlayerRankingInputPort interface {
	Execute(context.Context) (*GetPlayerRankingOutput, error)
}

type GetPlayerRankingOutput struct {
	Ranking []*model.Player
	Count   int
}

type GetPlayerRankingInteractor struct {
	PlayerAPIRepository repository.PlayerAPIRepository
}

func NewGetPlayerRankingUsecase(pr repository.PlayerAPIRepository) GetPlayerRankingInputPort {
	return &GetPlayerRankingInteractor{
		PlayerAPIRepository: pr,
	}
}

func (i *GetPlayerRankingInteractor) Execute(ctx context.Context) (*GetPlayerRankingOutput, error) {
	player, err := i.PlayerAPIRepository.GetPlayerList(ctx)
	if err != nil {
		return nil, err
	}

	if len(player) == 0 {
		return nil, errors.New("not found")
	}

	sort.Slice(player, func(i, j int) bool {
		return player[i].Ranking < player[j].Ranking
	})

	output := &GetPlayerRankingOutput{
		Ranking: player,
		Count:   len(player),
	}

	return output, nil
}

次にテストを作成します。
テストファイル get_player_ranking_test.go を作成し、テストを実装します。

usecase/get_player_ranking_test.go
package usecase_test

import (
	"context"
	"testing"

	"github.com/golang/mock/gomock"
	"github.com/stretchr/testify/require"

	"example.com/model"
	repository "example.com/repository/mock"
	"example.com/usecase"
)

func TestGetPlayerRankingInteractor_Execute(t *testing.T) {
	ctx := context.Background()
	resp := []*model.Player{
		{ID: "1", Name: "test-player-1", Ranking: 3},
		{ID: "2", Name: "test-player-2", Ranking: 2},
		{ID: "3", Name: "test-player-3", Ranking: 1},
	}

	// (1) モックを呼び出すための Controller を生成
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    // (2) モックの生成
    pr := repository.NewMockPlayerAPIRepository(ctrl)
    // (3) テストに呼ばれるべきメソッドと引数・戻り値を指定
    pr.EXPECT().GetPlayerList(ctx).Return(resp, nil)

    // (4) テストの本体
    output, err := usecase.NewGetPlayerRankingUsecase(pr).Execute(ctx)
    require.NoError(t, err)
    require.Len(t, output.Ranking, 3)
    for i, p := range output.Ranking {
        require.Equal(t, i+1, p.Ranking)
    }
    require.Equal(t, 3, output.Count)
}

上記のテストで何を行ったのか解説します。

  1. モックを呼び出すための Controller を生成
    • 最初に、gomock.NewController(t) で、モックを使用するための Controller を生成します
    • 後処理の ctrl.Finish() は、後述するモックが全て呼び出されたかどうかを確認しています
  2. モックの生成
    • PlayerAPIRepository インターフェースのモックを作成しています
    • 前述した NewMockPlayerAPIRepository 関数でモックを作成します
  3. テストに呼ばれるべきメソッドと引数・戻り値を指定
    • EXPECT().Method() により、テスト中に対象のメソッドが呼び出されることを期待します
      • ctrl.Finish() の後処理までに対応するメソッドが実行されないとエラーになります
        つまり、EXPECT() によってモックのメソッドが呼び出されたかどうかのテストができます
    • EXPECT() は、*gomock.Call を返します
    • Return() は、モックの呼び出しの戻り値が指定できます
  4. テストの本体

より複雑なテストを書いてみる

先ほどのサンプルより少し複雑なテストを書いていきます。
API によるプレイヤー情報の取得(GetPlayerList メソッド)からエラーが返却された場合にリトライする処理を追加します。

usecase/get_player_ranking.go
func (i *GetPlayerRankingInteractor) Execute(ctx context.Context) (*GetPlayerRankingOutput, error) {
	player, err := i.PlayerAPIRepository.GetPlayerList(ctx)
	if err != nil {
        // リトライする
        player, err := i.PlayerAPIRepository.GetPlayerList(ctx)
        if err != nil {
            return nil, err
        }
	}

    // 省略

	return output, nil
}

gomock.InOrder 関数を使うことで、モックの呼び出し順序を指定することができます。
こちらを使うことで上記のようなリトライ処理のテストを書くことができます。

usecase/get_player_ranking_test.go
func TestGetPlayerRankingInteractor_Execute(t *testing.T) {
	ctx := context.Background()
	resp := []*model.Player{
		{ID: "1", Name: "test-player-1", Ranking: 3},
		{ID: "2", Name: "test-player-2", Ranking: 2},
		{ID: "3", Name: "test-player-3", Ranking: 1},
	}

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

	pr := repository.NewMockPlayerAPIRepository(ctrl)
	// モックが呼び出される順番を指定する
	gomock.InOrder(
		pr.EXPECT().GetPlayerList(ctx).Return(nil, errors.New("timeout")),
		pr.EXPECT().GetPlayerList(ctx).Return(resp, nil),
	)

	output, err := usecase.NewGetPlayerRankingUsecase(pr).Execute(ctx)
	require.NoError(t, err)
	require.Len(t, output.Ranking, 3)
	for i, p := range output.Ranking {
		require.Equal(t, i+1, p.Ranking)
	}
	require.Equal(t, 3, output.Count)
}

上記のテストについて解説します。

  • gomock.InOrder を使い、GetPlayerList メソッドの呼び出し順を指定します
    • 1回目 … エラーを返却する
    • 2回目 … 正常にプレイヤー情報の一覧を返却する

つまり、GetPlayerList メソッドがエラーを返した際に、再度 GetPlayerList メソッドが実行されないとテストは失敗します。

おまけ

その他にも以下のようなメソッド等を使うことでモックの呼び出しを指定することができます。

  • *gomock.Call.Times メソッドで、メソッドの呼び出し回数が指定
    pr.EXPECT().GetPlayerList(ctx).Return(resp, nil).Times(2)
    
  • *gomock.Call.AnyTimes メソッドで、メソッドの呼び出しが複数回呼べることを指定
    pr.EXPECT().GetPlayerList(ctx).Return(resp, nil).AnyTimes()
    
  • gomock.Any() で、常に一致する引数を指定
    pr.EXPECT().Save(ctx, gomock.Any())
    
  • *gomock.Call.DoAndReturn メソッドで、呼び出し後のアクションと戻り値を指定
    pr.EXPECT().Save(ctx, gomock.Any()).
        DoAndReturn(func(ctx context.Context, player *model.Player) error {
            require.Equal(t, "12345", player.ID)
            require.Equal(t, "test-player", player.Name)
            require.Equal(t, 1, player.Ranking)
            return nil
        })
    

さいごに

gomock の基本的な使用方法から複雑なテストコードを書くところまで説明しました。
外部のAPIやDBサーバへの接続を行う処理などを使ったテストでもモックを使うことで簡単に実装ができ、より複雑なテストが書けます。

ぜひ gomock を使ってテストを書いてみてください。

明日は @edi_t さんです。お楽しみに。

16
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?