はじめに
レイヤードアーキテクチャ、クリーンアーキテクチャ、DIなどのアーキテクチャを使うリポジトリを前提とした話です。
gitOps,CIで単体パッケージだけで完結する単体テストを行う際に依存するモジュールをモックすると思います。
その場合はそのソースコードだけでテストが完結するはずです。
func TestFoo(t *testing.T) {
...
mock := gomock.NewBar(ctrl)
...
target := &Foo{bar: mock}
target.Do()
...
}
E2Eテストの場合は実際にmain関数相当のものを立ち上げ、Webサーバーなら実際にhttpリクエストを送信してレスポンスの検証等をするために、に依存するDBなど外部ミドルウェアのmockサーバーが起動されlistenするようにクライアントをバインドする構成が多いと思います。
func TestE2E(t *testing.T) {
db := newDB()
target := &Target{DB: db}
mux := http.NewServeMux()
mux.HandleFunc("/", target.Func)
s := &http.Server{
Addr: ":8080",
Handler: mux,
}
go func() {
s.ListenAndServe()
}()
res, err := http.Get("http://localhost:8080")
...
}
この場合は外部サーバへのオーバーヘッドや外部サーバの立ち上げ時間など単体テストよりもリソースコストやCI時間が長くなりがちです。
DBへのアクセス時間にオーバヘッドの大部分が存在するのでいっそのことその末端部分だけmockにすることで全体コストの削減が図れないかなという考察をこの記事では記載します。
E2E(End to end)
の末端(2個目のE)の手前(D->E
)という意味でE2D
と名付けました。
各パターンでの説明
説明に使うソースコードはここに置いてます。
シンプルパターンでの説明
ごく単純化して説明するために構造だけ示したソースコードで説明します。
このディレクトリにはlib1〜3
それぞれ下記のような構造のパッケージが記述されています。
package lib1
import "log"
//go:generate mockgen -source=lib1.go -destination=mocks/lib1.go -package=mocks
type Lib2 interface {
Do() error
}
type Lib struct {
Lib Lib2
}
func (l *Lib) Do() error {
_ = l.Lib.Do()
log.Println(`this is lib1`)
return nil
}
そしてmain関数でDIしています。
lib1->lib2->lib3
の順番に依存させるコードです。
package main
import (
"e2dlabo/simple/lib1"
"e2dlabo/simple/lib2"
"e2dlabo/simple/lib3"
"log"
)
func main() {
lib := lib1.Lib{
Lib: &lib2.Lib{
Lib: &lib3.Lib{},
},
}
err := lib.Do()
if err != nil {
log.Fatal(err)
}
log.Println("yeah")
}
これはE2Dテストコードです。
lib1->lib2->lib3のモック
という依存のさせ方で末端パッケージをモックさせるテストを実現しました。
要は依存するパッケージのうちの末端パッケージだけmock化するということです。
かなり単純化していますが、やりたいことはこういうことです。
package e2d
import (
"e2dlabo/simple/e2d/mocks"
"e2dlabo/simple/lib1"
"e2dlabo/simple/lib2"
"log"
"testing"
"github.com/golang/mock/gomock"
)
func Test(t *testing.T) {
ctrl := gomock.NewController(t)
lib3Mock := mocks.NewMockLib3(ctrl)
lib3Mock.EXPECT().Do().DoAndReturn(func() error {
log.Println("this is mock")
return nil
})
lib := lib1.Lib{
Lib: &lib2.Lib{
Lib: lib3Mock,
},
}
err := lib.Do()
if err != nil {
log.Fatal(err)
}
log.Println("yeah")
}
現実世界での説明
前述したものはかなり単純化した例だったので今度は実際にwebサーバを実装した例で検証してみます。
説明に使うディレクトリはこちら
ディレクトリは下記のような構成になっており前セクションと同じく各パッケージはinterfaceで通信することを前提としていて、cmd/web.go
でDIをされます。
real_world/
|- cmd
| `- web.go
|- e2d
| `- e2d_test.go
|- handlers # webサーバのハンドラ層
| `- yeah.go
|- services # ビジネスロジック的な層(usecaseに近い)
| `- user.go
|- infrastructures # DBのラッパー層(adapterに近い)
| `- db.go
`- models # domain層的なところ
`- user.go
下記のようにmain.goでDIされます。
前述と違うのは、infrastructures.NewDB
によって具体的なDBクライアントが作られてしまうので、以降の処理はDBへの接続オーバーヘッドが発生してしまう点です。
package main
import (
"e2dlabo/real_world/handlers"
"e2dlabo/real_world/infrastructures"
"e2dlabo/real_world/services"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "ok"})
})
db, err := infrastructures.NewDB(`user:password@tcp(0.0.0.0:3306)/real_world`)
if err != nil {
log.Fatalln(err)
}
h := &handlers.Yeah{
UserService: &services.UserService{
DB: db,
},
}
r.GET("/me", h.Me)
err = r.Run(":3000")
if err != nil {
log.Fatalln(err)
}
}
こちらはE2Dテストです。
database/sql
の*sql.DB
をmockすることで外部DBへのオーバーヘッド予防を実現しています。
package e2d
import (
"e2dlabo/real_world/handlers"
"e2dlabo/real_world/infrastructures"
"e2dlabo/real_world/services"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func Test(t *testing.T) {
db, mock, _ := sqlmock.New(sqlmock.MonitorPingsOption(true))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM users WHERE id = ? LIMIT 1`)).WillReturnRows(
sqlmock.NewRows([]string{"id", "name", "phone_number"}).AddRow("hoge", "hoge", "09012345678"))
d := &infrastructures.DB{
DB: db,
}
target := &handlers.Yeah{
UserService: &services.UserService{
DB: d,
},
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
target.Me(c)
assert.Equal(t, http.StatusOK, c.Writer.Status())
}
google/wireを使った発展系
wireを知っていますか?
実際のプロダクションコードは規模が大きいので、前述したmain関数のDIくらいの記述量で済むことはありません。
とても長いmain関数のコードが並んでしまいます。
それを解決するのがwireです。
このように依存するオブジェクトを生み出すProvider
関数を列挙することで冗長コードを省略しつつDIを実現します。
wire.Build(
handlers.New,
services.New,
infrastructures.New,
)
詳しくはチュートリアルを参照してみてください。
今回のreal_world
のコードも下記のようにwireを使ってDIすることができます。
説明しているソースコードはこちらです
前述の例と同じ感じでhandlers -> services -> infrastructures -> dbクライアント
の順に依存しています。
func Initialize(dsn string) (*handlers.Yeah, error) {
wire.Build(
handlers.NewYeah,
wire.Bind(new(handlers.UserService), new(*services.UserService)),
services.NewUserService,
wire.Bind(new(services.DB), new(*infrastructures.DB)),
infrastructures.NewDB,
)
return nil, nil
}
では、webサーバーで使うDIとE2Dテストで使うDIの共通部分を切り出して再利用してみましょう。
こちらはDI共通部分です。
wireではこのように共通化させたいDIをwire.NewSet
メソッドで定義することができます。
package wire
import (
"e2dlabo/real_world/handlers"
"e2dlabo/real_world/services"
"github.com/google/wire"
)
var EndSet = wire.NewSet(
handlers.NewYeah,
wire.Bind(new(handlers.UserService), new(*services.UserService)),
services.NewUserService,
)
ここで言う共通部分とは、handlers -> services -> infrastructures
までです。
コード上はservices.NewUserService
までしか書かれていませんが、実際の引数はinterfaceの実装=infrastructures層のDB
をセットする予定なので分かりにくいですが暗黙的に含まれます。
func NewUserServices(db services.DB)
次はwebサーバーとしてのDIです。
dsn=Data source name
を取得すると内部でDBクライアントを生成してくれるinfrastructures.NewDB
を末端として定義しています。
//go:build wireinject
// +build wireinject
package wire
import (
"e2dlabo/real_world/handlers"
"e2dlabo/real_world/infrastructures"
"e2dlabo/real_world/services"
wire2 "e2dlabo/real_world/wire/wire"
"github.com/google/wire"
)
func Initialize(dsn string) (*handlers.Yeah, error) {
wire.Build(
wire2.EndSet,
wire.Bind(new(services.DB), new(*infrastructures.DB)),
infrastructures.NewDB,
)
return nil, nil
}
こちらがE2Dテストで使うDIです。
dabase/sql
の*sql.DB(DBクライアント)
を末端として取り扱うようにしています。
//go:build wireinject
// +build wireinject
package wire
import (
"database/sql"
"e2dlabo/real_world/handlers"
"e2dlabo/real_world/infrastructures"
"e2dlabo/real_world/services"
wire2 "e2dlabo/real_world/wire/wire"
"github.com/google/wire"
)
func Initialize(db *sql.DB) (*handlers.Yeah, error) {
wire.Build(
wire2.EndSet,
wire.Bind(new(services.DB), new(*infrastructures.DB)),
newDB,
wire.Bind(new(infrastructures.SqlDB), new(*sql.DB)),
)
return nil, nil
}
func newDB(db infrastructures.SqlDB) *infrastructures.DB {
return &infrastructures.DB{
DB: db,
}
}
そしてテストコードではgo-sqlmockで作った*sql.DB
をセットすることで末端をmockにしています。
package e2d
import (
"e2dlabo/real_world/wire/e2d/wire"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func Test(t *testing.T) {
db, mock, _ := sqlmock.New(sqlmock.MonitorPingsOption(true))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM users WHERE id = ? LIMIT 1`)).WillReturnRows(
sqlmock.NewRows([]string{"id", "name", "phone_number"}).AddRow("hoge", "hoge", "09012345678"))
target, _ := wire.Initialize(db)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
target.Me(c)
assert.Equal(t, http.StatusOK, c.Writer.Status())
}
最後に
E2Dテストという考え方はいかがでしたか?
E2Dテストによって下記のような効能が出てくると思います。
- ソースコードだけでテストが完結するけど、E2Eテストに近いことができる。
- 外部サーバに依存しないので同時実行するテスト同士が干渉しない。
- テスト時間、CIコストを節約できる。
- DIを再利用できるwireと親和性高いんではないか?
Enjoy!