19
3

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 3 years have passed since last update.

株式会社カオナビAdvent Calendar 2021

Day 12

wireを使ったプロジェクトでモックを用いたAPIのテストを書く

Last updated at Posted at 2021-12-11

この記事は カオナビ Advent Calendar 2021 12日目です。

はじめに

社内のAPI開発ではWebフレームワークとして gin、DIを行うために wire を使用しています。
(gin, wireについては割愛します)

API開発をするプロジェクトではhttptestパッケージを使用したAPIのテストを重点的に書くようにしていますが、
wireを導入した際にtimeパッケージ等をモック化したテストがうまくいかず苦戦しました。

今回はAPI開発のプロジェクトにwireを導入した時に、モックを使ってテストを書けるようにする方法を書いてみます。

課題

wireのBest PracticesにはMockingについての記述があります。
書かれている通りに実装するとテスト専用のインジェクターを作成する必要があり、さらにテスト専用のルーティングも必要になってしまいました。

ルーティングは本番稼働するものと全く同じものを使用したかったため、Best Practicesの方法だと要求を満たせませんでした。

モック化の手法

今回はwireのBest Practicesの方法も含めて3パターンの手法でモック化をしていきます。
Approach A/BはBest PracticesのMockingにある手法を参考にしています。
もちろん他にも手法はあると思いますので一例としてみていただければと思います。

  1. Approach A
    • モックを引数として受け取るテスト専用のインジェクターを作成する手法
  2. Approach B
    • アプリと、モックしたいすべての依存関係を含む新しい構造体を作成し、この構造体を返す専用のインジェクターを作成する手法
  3. Approach C
    • wireに依存関係を返すコンストラクタを直接設定しない
    • 依存関係を返す関数を一つ挟んであげるという手法です。
    • 現在はこのやり方を採用しています

まとめ

以降はサンプルコードで長くなるので先にまとめを書いておきます。

現在はApproche Cの方法を用いてテストを書いていて、モック化しているinterfaceが10個ほどあります。
モック化の対象をtimeパッケージなど実行ごとに結果が変わってしまうもの、外部のAPIを実行するコードに絞ることで対象が増えすぎないようにしています。
10個でも多いとは感じていますが、APIごとにモック化が必要なinterfaceは1,2個なので今の所はそれほど苦では無いです。

Approach A/Bではテスト用のインジェンクターを作る + テスト用のルーティングを作る必要があるという点が気になったので使用していません。
(やり方を工夫すればテスト用のインジェクターを作らなくてもいけそうですが)

他にいいやり方があれば是非教えていただきたいです!!


ここからはサンプルコードです

ベースになるAPIとテストの作成

まずはgin + wireを使って動く簡単なAPIとAPIのテストを作ります。
今回は構成はこのような形で作成します。

.
├── app
│   ├── handler.go
│   ├── route.go
│   ├── timer.go
│   ├── wire.go
│   └── wire_gen.go
├── main.go
└── test
    └── api_test.go

main.go

ここではgin.Engine.Run()を呼び出すだけです。

package main

import "adcal/app"

func main() {
	if err := app.Route().Run(); err != nil {
		panic(err)
	}
}

app/route.go

gin.Engineの初期化はPublicメソッドとして切り出します。
main.goから呼び出しますし、後述のテストからも使用するためです。
こうすることでAPIのテストも実際のルーティングと全く同じ状態でテストすることが出来ます。

package app

import (
	"github.com/gin-gonic/gin"
)

func Route() *gin.Engine {
	e := gin.New()
	e.GET("sample", InitializeHandler().Run)
	return e
}

app/timer.go

timeパッケージを直接使うと日付に依存してしまうため、interfaceを用意してtimeパッケージを直接使用しないようにしています。
interface化しておくことで後でモックに差し替えが可能になります。

package app

import "time"

type Timer interface {
	Now() time.Time
}

func NewTimer() Timer {
	return &timer{}
}

type timer struct{}

func (t *timer) Now() time.Time {
	loc,_:=time.LoadLocation("Asia/Tokyo")
	return time.Now().In(loc)
}

app/handler.go

現在時刻を返すだけのAPIです。
コンストラクタを用意してTimerのinterfaceを受け取れるようにしておきます。

package app

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

type Handler struct {
	t Timer
}

func NewHandler(t Timer) *Handler {
	return &Handler{t}
}

func (c *Handler) Run(ctx *gin.Context) {
	ctx.JSON(http.StatusOK, gin.H{
		"now": c.t.Now().Format("2006-01-02 15:04:05"),
	})
}

app/wire.go

wireでDIするための設定です。
wire.BindにはHandler, Timerのコンストラクタを渡します。
この関数はnilを返却してしまって問題ありません。

//go:build wireinject
// +build wireinject

package app

import "github.com/google/wire"

func InitializeHandler() *Handler {
	wire.Build(NewHandler, NewTimer)
	return nil
}

app/wire_gen.go

ここまでコードを作成しwireコマンドを実行すると出力されるファイルです。

// Code generated by Wire. DO NOT EDIT.

//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package app

// Injectors from wire.go:

func InitializeHandler() *Handler {
	appTimer := NewTimer()
	handler := NewHandler(appTimer)
	return handler
}

test/api_test.go

APIのテストはhttptestパッケージを使用して実装します。
gin.Enginehttp.Handler interfaceを満たすため、httptest.NewServer()に渡しててあげるだけでOKです。

レスポンスボディの現在日は 2021-12-12 12:00:00 を返すことを期待するようにしています。
そのためこのテストは2021年12月12日 12時00分00秒に実行しない限りは必ず失敗します。

package app_test

import (
	"adcal/before/app"
	"io"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
)

func Test(t *testing.T) {
	gin.SetMode(gin.TestMode)

	serv := httptest.NewServer(app.Route())
	req, err := http.NewRequest(http.MethodGet, serv.URL+"/sample", nil)
	if err != nil {
		t.Fatal(err)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Errorf("status returns %d, want %d", resp.StatusCode, http.StatusOK)
	}

	b, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatal(err)
	}

	e := `{"now":"2021-12-12 12:00:00"}`
	a := string(b)
	if a != e {
		t.Errorf("body returns %s, want %s", a, e)
	}
}

実行結果

$ go test -v ./test/...
=== RUN   Test
    api_test.go:41: body returns {"now":"2021-12-05 22:32:43"}, want {"now":"2021-12-12 12:00:00"}
--- FAIL: Test (0.00s)
FAIL
FAIL    adcal/before/test       0.122s
FAIL

現時点ではTimerをモック化できるようになっていないためテストは失敗します。
このコードからTimerをモックに置き換えてテストできるように修正していきます。


Approach A

wireのBest Practicesに記載されているApproach Aの方法でテストしてみます。

モックを引数として受け取るテスト専用のインジェクターを作成する、という手法になります。

Approach A: Pass mocks to the injector

Create a test-only injector that takes all of the mocks as arguments; the argument types must be the interface types the mocks are mocking. wire.Build can't include providers for the mocked dependencies without creating conflicts, so if you're using provider set(s) you will need to define one that doesn't include the mocked types.

1. テスト専用のインジェクターを作成する

wire.goMockInitializeHandler()を追加し、Timer interfaceを引数から受け取るようにします。

//go:build wireinject
// +build wireinject

package app

import "github.com/google/wire"

func InitializeHandler() *Handler {
	wire.Build(NewHandler, NewTimer)
	return nil
}

// Timerを引数に受け取るイジェクターを追加します
func InitializeMockHandler(mt Timer) *Handler {
	wire.Build(NewHandler)
	return nil
}

この状態でwireを実行すると以下のファイルが生成されます。

// Code generated by Wire. DO NOT EDIT.

//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package app

// Injectors from wire.go:

func InitializeHandler() *Handler {
	appTimer := NewTimer()
	handler := NewHandler(appTimer)
	return handler
}

func InitializeMockHandler(mt Timer) *Handler {
	handler := NewHandler(mt)
	return handler
}

2. テスト専用のルーティングを作成

作成済みのRoute()関数にモック用のインジェクターを差し込むことは出来ないので
テスト専用のルーティングメソッドとしてTestRoute()を用意します。
InitializeMockHandler()に渡すTimer interafaceを引数で受け取る必要があります。

package app

import (
	"github.com/gin-gonic/gin"
)

func Route() *gin.Engine {
	e := gin.New()
	e.GET("sample", InitializeHandler().Run)
	return e
}

func TestRoute(mt Timer) *gin.Engine {
	e := gin.New()
	e.GET("sample", InitializeMockHandler(mt).Run)
	return e
}

3. テストの修正

Time interfaceを満たすmockTimerを用意し、固定の日付を返却できるようにします。
httptest.NewServer()には先ほど作成したテスト用のTestRoute()の戻り値を設定します。

type mockTimer struct{}

func (m *mockTimer) Now() time.Time {
	loc, _ := time.LoadLocation("Asia/Tokyo")
	return time.Date(2021, time.December, 12, 12, 00, 00, 0, loc)
}

func TestApproachA(t *testing.T) {
	gin.SetMode(gin.TestMode)

	// TestRouteにTimerのモックを渡します
	serv := httptest.NewServer(app.TestRoute(&mockTimer{}))
	req, err := http.NewRequest(http.MethodGet, serv.URL+"/sample", nil)

	// ... 省略
}

実行結果

$ go test -v ./test/...
=== RUN   TestApproachA
--- PASS: TestApproachA (0.00s)
PASS
ok      adcal/approach/a/test   0.296s

まとめ

テスト専用のインジェクターを毎回作るのはちょっと面倒くさいのと
テスト用のルーティングも用意する必要があるため、 app.Route()をそのまま使えないのがちょっと気になります。

またサンプルコードの作りだと、モック化したい依存関係が増えた時にTestRoute()の引数が多くなるので扱いにくくなってしまいます。

TestRouteの引数を無くし、testhelperパッケージ等を作って内部でモックを取得したり、
依存関係を構造体にまとめたものをTestRouteの引数にしたりすると多少扱いやすくはなりそうですが。

func TestRoute() *gin.Engine {
	e := gin.New()
	mt := testhelper.NewMockTimer()
	e.GET("sample", InitializeMockHandler(mt).Run)
	return e
}

func TestRoute(d Depends) *gin.Engine {
	e := gin.New()
	e.GET("sample", InitializeMockHandler(d.Timer).Run)
	return e
}

Approach B

wireのBest Practicesに記載されているApproach Bの方法でテストしてみます。

アプリと、モックしたいすべての依存関係を含む新しい構造体を作成し、この構造体を返す専用のインジェクターを作成する、という手法になります。

Approach B: Return the mocks from the injector

Create a new struct that includes the app plus all of the dependencies you want to mock. Create a test-only injector that returns this struct, give it providers for the concrete mock types, and use wire.Bind to tell Wire that the concrete mock types should be used to fulfill the appropriate interface.

1. 依存関係を含む新しい構造体を作成する

wire_b.goを新規作成し、依存関係を含む構造体を追加します。
MockHandler.Timerにはモック化したTimer interfaceを埋め込めるようにしておく必要があります。
またテストで日付を変更できるようにするためにtime.TimeをPublic変数として定義しておきます。

package app

import "time"

var MT time.Time

type MockHandler struct {
	Handler *Handler
	Timer   *MockTimer
}

type MockTimer struct{}

func NewMockTimer() *MockTimer {
	return &MockTimer{}
}

func (m *MockTimer) Now() time.Time {
	return MT
}

さらにwire.goにMockHandlerを返すインジェクターを追加します。

//go:build wireinject
// +build wireinject

package app

import "github.com/google/wire"

func InitializeHandler() *Handler {
	wire.Build(NewHandler, NewTimer)
	return nil
}

func InitializeMockHandler() *MockHandler {
	wire.Build(
		wire.Struct(new(MockHandler), "*"),
		NewHandler,
		newMockTimer,
		wire.Bind(new(Timer), new(*MockTimer)),
	)
	return nil
}

この状態でwireを実行すると以下のファイルが生成されます。

// Code generated by Wire. DO NOT EDIT.

//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package app

// Injectors from wire.go:

func InitializeHandler() *Handler {
	appTimer := NewTimer()
	handler := NewHandler(appTimer)
	return handler
}

func InitializeMockHandler() *MockHandler {
	mockTimer := newMockTimer()
	handler := NewHandler(mockTimer)
	mockHandler := &MockHandler{
		Handler: handler,
		Timer:   mockTimer,
	}
	return mockHandler
}

2. テスト専用のルーティングを作成

作成済みのRoute()関数は使用できないため
テスト専用のルーティングメソッドとしてTestRoute()を用意します。

package app

import (
	"github.com/gin-gonic/gin"
)

func Route() *gin.Engine {
	e := gin.New()
	e.GET("sample", InitializeHandler().Run)
	return e
}

func TestRoute() *gin.Engine {
	e := gin.New()
	e.GET("sample", InitializeMockHandler().Handler.Run)
	return e
}

3. テストの修正

httptest.NewServer()には先ほど作成したテスト用のTestRoute()の戻り値を設定します。
app.MTにテスト用の日付を入れておきます

func TestApproachB(t *testing.T) {
	gin.SetMode(gin.TestMode)

	loc, _ := time.LoadLocation("Asia/Tokyo")
	app.MT = time.Date(2021, time.December, 12, 12, 00, 00, 0, loc)

	serv := httptest.NewServer(app.TestRoute())
	req, err := http.NewRequest(http.MethodGet, serv.URL+"/sample", nil)

	// ... 省略
}

実行結果

$ go test -v ./test/...
=== RUN   TestApproachB
--- PASS: TestApproachB (0.00s)
PASS
ok      adcal/approach/b/test   0.207s

まとめ

このパターンでもテスト専用のインジェクターを毎回作る必要がありますが、Approach Aよりも複雑なインジェクターを作らなければなりません。
そしてApproach Aと同様にテスト用のルーティングを用意する必要がある点が気になりました。


Approach C

これはwireのBest Practicesにはない手法になります。

依存関係を返すコンストラクタを実行する関数を作成し、コンストラクタ自体を書き換えるという方法になります。

1. 依存関係を返す関数を用意する

wire_c.goとして以下のファイルを追加します。

まずはパッケージのPublic変数としてInitializeTimerFuncを定義し、値に依存関係(今回はTimer interface)を返すコンストラクタを設定します。
InitializeTimerFuncfunc() Timer型の変数となります)

次にInitializeTimerFuncの実行結果を返すInitializeTimer()を追加します。

デフォルト値としてNewTimerを設定しているため、テスト以外ではtimer.goに作った現在日時を返すTimerが使用されます。

package app

var InitializeTimerFunc func() Timer = NewTimer

func InitializeTimer() Timer {
	return InitializeTimerFunc()
}

2. wireの設定を変更する

wire.BindにはNewTimerの代わりに、先程追加したInitializeTimer関数を設定します。

//go:build wireinject
// +build wireinject

package app

import "github.com/google/wire"

func InitializeHandler() *Handler {
	wire.Build(NewHandler, InitializeTimer)
	return nil
}

この状態でwireを実行すると以下のファイルが生成されます。
InitializeTimer()の実行結果をNewHandler()に渡す、という挙動に変わります

// Code generated by Wire. DO NOT EDIT.

//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package app

// Injectors from wire.go:

func InitializeHandler() *Handler {
	appTimer := InitializeTimer()
	handler := NewHandler(appTimer)
	return handler
}

3. ルーティングの修正

このパターンではルーティングには一切手を加えません。

4. テストの修正

ここでもTime interfaceを満たすmockTimerを用意し、固定の日付を返却できるようにします。

app.InitializeTimerFuncにmockTimerを返す関数を設定します。

app.InitializeTimerFuncを上書きするので念の為deferで前の状態に戻せるようにしておくと良いと思います。

ルーティングは変更してないのでhttptest.NewServer()にはapp.Route()の戻り値を設定できます。

type mockTimer struct{}

func (m *mockTimer) Now() time.Time {
	loc, _ := time.LoadLocation("Asia/Tokyo")
	return time.Date(2021, time.December, 12, 12, 00, 00, 0, loc)
}

func TestApproachC(t *testing.T) {
	gin.SetMode(gin.TestMode)

	old := app.InitializeTimerFunc
	defer func() { app.InitializeTimerFunc = old }()
	// InitializeTimerFuncにTimerのモックを返すコンストラクタを注入します
	app.InitializeTimerFunc = func() app.Timer { return &mockTimer{} }

	serv := httptest.NewServer(app.Route())
	req, err := http.NewRequest(http.MethodGet, serv.URL+"/sample", nil)

	// ... 省略
}

実行結果

$ go test -v ./test/...
=== RUN   TestApproachC
--- PASS: TestApproachC (0.00s)
PASS
ok      adcal/approach/c/test   0.232s

まとめ

このパターンではコンストラクタをラップする手間が増えますが
インジェクターの追加なし、app.Route()の変更なしでAPIをテストを実行することができました。

モック化したいコードが増えた場合はvar InitializeXxxFuncfunc InitializeXxx()のセットが増えていきます。

19
3
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
19
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?