4
2

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.

GoAdvent Calendar 2022

Day 24

Gomockはじめてみませんか

Last updated at Posted at 2022-12-23

みなさんは、handlerやapplicationのテストをする際にモックをどうしていますか??
私は自作モックを使うこともありますが、Gomockを愛用しています!
Gomockは、たくさん記事がありますが私なりの使い方を共有できればと思います:tea:
導入を検討しているかたの参考になればと思います。

前提

  • Go v1.19.3
    • github.com/golang/mock@v1.6.01
      • github.com/golang/mock/mockgen1
    • go.uber.org/mock@v0.2.0
      • go.uber.org/mock/mockgen
    • github.com/labstack/echo@v4.9.1

サンプルコード

使い方

mockgenをとおしてinterfaceを含んだファイル作成したら、あとはテストで自由に使うだけです。

導入

$ go install github.com/uber-go/mock/mockgen@latest

モックファイル作成

$ mockgen -source=api/application/application.go -destination=./mock/application/mock_application.go

-source: A file containing interfaces to be mocked.
-destination: A file to which to write the resulting source code. If you don't set this, the code is printed to standard output.

mockgenを使う際には、sourceモードとreflectモードの2種類あります。
今回のケースでは、前者を指定してモックを作成します。

Makefileのサンプル

Makefile
.PHONY: mockgen
mockgen:
	docker-compose exec your_container mockgen -source=api/$(Path)/$(FileName) -destination=./mock/$(Path)/mock_$(FileName)

go generateのサンプル

いままでMakefileを用意して、コマンド実行していましたがgo generateで作成した方が楽そうです。
下記の例はモックを作成したいファイルと同じ階層に、mockgenのためだけに使うサンプルです。
ただ現状の問題点として、mockを集約しているのでパス(destination)間違いが頻繁に起きます...w

application/mockgen.go
//go:generate mockgen -source=application.go -destination=../../mock/application/mock_application.go
package application

実際にできたもの

書いてあることそのままですが、書き換えないでね〜とのことです。
(実際にヒューマンエラーで書き換えられたこともあった...)

mock/application/mock_application.go
// Code generated by MockGen. DO NOT EDIT.
// Source: application.go

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

import (
	gomock "github.com/uber-go/mock/gomock"
)

// MockApplicationInterface is a mock of ApplicationInterface interface.
type MockApplicationInterface struct {
	ctrl     *gomock.Controller
	recorder *MockApplicationInterfaceMockRecorder
}

// MockApplicationInterfaceMockRecorder is the mock recorder for MockApplicationInterface.
type MockApplicationInterfaceMockRecorder struct {
	mock *MockApplicationInterface
}

// NewMockApplicationInterface creates a new mock instance.
func NewMockApplicationInterface(ctrl *gomock.Controller) *MockApplicationInterface {
	mock := &MockApplicationInterface{ctrl: ctrl}
	mock.recorder = &MockApplicationInterfaceMockRecorder{mock}
	return mock
}

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

テスト実装

handlerの実際のテストを行います。
mockを用いてapplicationの処理を閉じ込め、処理の成功・失敗パターンを網羅します。
※リクエストのバインドした際のエラーについては今回は考慮しません。

ハンドラのコード

handler/handler.go
package handler

// 中略

func (h *Handler) AssignRoutes(e *echo.Echo) {
	v1g := e.Group("v1")
	{
		v1bg := v1g.Group("/books")
		{
			v1bg.GET("/:uuid", h.GetBook)
		}
	}
}
handler/book.go
package handler

import (
	"net/http"
	"server/api/handler/request"

	"github.com/labstack/echo/v4"
)

func (h *Handler) GetBook(ec echo.Context) error {
	var req request.GetBookRequest
	if err := ec.Bind(&req); err != nil {
		return h.NewErrorResponse(ec, err)
	}

	ctx := h.GetCtx(ec)
    // ↓下記をモックする↓
	res, err := h.Application.GetBook(ctx, req.UUID)
	if err != nil {
        // ↓下記をモックする↓
		return h.NewErrorResponse(ec, err)
	}

	return ec.JSON(http.StatusOK, res)
}

テストコード

handler/book_test.go
package handler_test

import (
	"context"
	"net/http"
	"net/http/httptest"
	"server/api/domain/model"
	"server/api/handler"
	mock_application "server/mock/application"
	mock_i18n "server/mock/client/i18n"
	"strings"
	"testing"

    "go.uber.org/mock/gomock"
	"github.com/labstack/echo/v4"
	"golang.org/x/xerrors"
)

func TestGetBookSuccess(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	// Vars
	id := 1
	name := "scenario_name"
	uuid := "test_handler_uuid"
	ctx := context.TODO()

	// SetUp
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
	rec := httptest.NewRecorder()
	c := e.NewContext(req, rec)
	c.SetPath("/v1/books/:uuid")
	c.SetParamNames("uuid")
	c.SetParamValues(uuid)

	// IO
	appRes := &model.Book{
		ID:   id,
		Name: name,
		UUID: uuid,
	}

	// MockApplication
	app := mock_application.NewMockApplicationInterface(ctrl)
	app.EXPECT().GetBook(ctx, uuid).Return(appRes, nil)

	// TestGetBook
	ch := handler.NewHandler(app, nil)
	if err := ch.GetBook(c); err != nil {
		t.Error(err)
	}

	// Status
	expCode := http.StatusOK
	recCode := rec.Code
	recBody := rec.Body

	// Check
	if expCode != recCode {
		t.Errorf("expected: %v \n real: %v", expCode, recCode)
	}
	if recBody == nil {
		t.Errorf("bodyの取得に失敗しています")
	}
	if !strings.Contains(recBody.String(), name) {
		t.Error("期待するNameが存在しません")
	}
}

func TestGetBookError(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	// Vars
	uuid := "test_uuid"
	errName := "err_test"
	jaErrName := "テストエラー"
	err := xerrors.New(errName)
	ctx := context.TODO()

	// SetUp
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
	rec := httptest.NewRecorder()
	c := e.NewContext(req, rec)
	c.SetPath("/v1/books/:uuid")
	c.SetParamNames("uuid")
	c.SetParamValues(uuid)

	// MockApplication
	app := mock_application.NewMockApplicationInterface(ctrl)
	app.EXPECT().GetBook(ctx, uuid).Return(nil, err)

	// MockClient
	i18nm := mock_i18n.NewMockI18nClientInterface(ctrl)
	i18nm.EXPECT().T(errName).Return(jaErrName)

	// TestGetBook
	ch := handler.NewHandler(app, i18nm)
	if err := ch.GetBook(c); err != nil {
		t.Error(err)
	}

	// Status
	expCode := http.StatusBadRequest
	recCode := rec.Code
	recBody := rec.Body

	// Check
	if expCode != recCode {
		t.Errorf("expected: %v \n real: %v", expCode, recCode)
	}
	if recBody == nil {
		t.Errorf("bodyの取得に失敗しています")
	}
	if !strings.Contains(recBody.String(), jaErrName) {
		t.Error("期待するエラーが存在しません")
	}
}

実行結果

$ go test -v ./...
start  handler test...
di
=== RUN   TestGetBookSuccess
--- PASS: TestGetBookSuccess (0.00s)
=== RUN   TestGetBookError
--- PASS: TestGetBookError (0.00s)
PASS
end  handler test...

エラー時の表示

下記は本来GetBooksを呼び出すところをGetBookを呼び出し際のエラーです。

handler/book.go
package handler

import (
	"net/http"
	"server/api/handler/request"

	"github.com/labstack/echo/v4"
)

func (h *Handler) GetBooks(ec echo.Context) error {
	ctx := h.GetCtx(ec)
	res, err := h.Application.GetBooks(ctx)
	if err != nil {
		return h.NewErrorResponse(ec, err)
	}

	return ec.JSON(http.StatusOK, res)
}
handler/book_test.go
package handler


func TestGetBooksSuccess(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	// Vars
	id := 1
	name := "scenario_name"
	uuid := "test_handler_uuid"
	ctx := context.TODO()

	// SetUp
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
	rec := httptest.NewRecorder()
	c := e.NewContext(req, rec)
	c.SetPath("/v1/books")

	// IO
	appRes := &model.Book{
		ID:   id,
		Name: name,
		UUID: uuid,
	}

	// MockApplication
    // モックの呼び出し関数間違い...
	app := mock_application.NewMockApplicationInterface(ctrl)
	app.EXPECT().GetBook(ctx, uuid).Return(appRes, nil)

	// TestGetBook
	ch := handler.NewHandler(app, nil)
	if err := ch.GetBooks(c); err != nil {
		t.Error(err)
	}

	// Status
	expCode := http.StatusOK
	recCode := rec.Code
	recBody := rec.Body

	// Check
	if expCode != recCode {
		t.Errorf("expected: %v \n real: %v", expCode, recCode)
	}
	if recBody == nil {
		t.Errorf("bodyの取得に失敗しています")
	}
	if !strings.Contains(recBody.String(), name) {
		t.Error("期待するNameが存在しません")
	}
}
関数呼び出し間違いのエラー
=== RUN   TestGetBooksSuccess
    book.go:27: Unexpected call to *mock_application.MockApplicationInterface.GetBooks([context.Background]) at /app/server/api/handler/book.go:27 because: there are no expected calls of the method "GetBooks" for that receiver
    controller.go:269: missing call(s) to *mock_application.MockApplicationInterface.GetBook(is equal to context.TODO (*context.emptyCtx), is equal to test_handler_uuid (string)) /app/server/api/handler/book_test.go:149
    controller.go:269: aborting test due to missing call(s)
--- FAIL: TestGetBooksSuccess (0.00s)

複数回同じモックを呼び出したいとき

「uuidの処理など複数回呼び出したい」且つ「結果が違う」場合、どうやるのかな〜とおもっていました。
gomock.InOrderで実行順序を指定してあげると1回目と2回目で期待する結果がえられました。

	um := mock_util.NewMockUUIDGen(ctrl)
	gomock.InOrder(
		um.EXPECT().GetString().Return(uuid, err),
		um.EXPECT().GetString().Return(userID, err),
	)

感想

gomockのエラー時の見づらさはありますが、(本人比で)テストが2倍楽しくなっています!
それでは、よきAPI開発を〜:tea:

  1. アーカイブされました。Update, June 2023: This repo and tool are no longer maintained. Please see go.uber.org/mock for a maintained fork instead. 2

4
2
1

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
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?