みなさんは、handlerやapplicationのテストをする際にモックをどうしていますか??
私は自作モックを使うこともありますが、Gomockを愛用しています!
Gomockは、たくさん記事がありますが私なりの使い方を共有できればと思います
導入を検討しているかたの参考になればと思います。
前提
- Go v1.19.3
サンプルコード
使い方
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のサンプル
.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
//go:generate mockgen -source=application.go -destination=../../mock/application/mock_application.go
package application
実際にできたもの
書いてあることそのままですが、書き換えないでね〜とのことです。
(実際にヒューマンエラーで書き換えられたこともあった...)
// 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の処理を閉じ込め、処理の成功・失敗パターンを網羅します。
※リクエストのバインドした際のエラーについては今回は考慮しません。
ハンドラのコード
package handler
// 中略
func (h *Handler) AssignRoutes(e *echo.Echo) {
v1g := e.Group("v1")
{
v1bg := v1g.Group("/books")
{
v1bg.GET("/:uuid", h.GetBook)
}
}
}
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)
}
テストコード
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
を呼び出し際のエラーです。
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)
}
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開発を〜