この記事は カオナビ 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にある手法を参考にしています。
もちろん他にも手法はあると思いますので一例としてみていただければと思います。
- Approach A
- モックを引数として受け取るテスト専用のインジェクターを作成する手法
- Approach B
- アプリと、モックしたいすべての依存関係を含む新しい構造体を作成し、この構造体を返す専用のインジェクターを作成する手法
- 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.Engine
はhttp.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.go
にMockInitializeHandler()
を追加し、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)を返すコンストラクタを設定します。
(InitializeTimerFunc
はfunc() 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 InitializeXxxFunc
とfunc InitializeXxx()
のセットが増えていきます。