0
0

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.

E2D(End to end - 1)の考察

Last updated at Posted at 2022-03-01

はじめに

レイヤードアーキテクチャ、クリーンアーキテクチャ、DIなどのアーキテクチャを使うリポジトリを前提とした話です。

gitOps,CIで単体パッケージだけで完結する単体テストを行う際に依存するモジュールをモックすると思います。
その場合はそのソースコードだけでテストが完結するはずです。

example_unit
func TestFoo(t *testing.T) {
  ...
  mock := gomock.NewBar(ctrl)
  ...
  target := &Foo{bar: mock}
  target.Do()
  ...
}

E2Eテストの場合は実際にmain関数相当のものを立ち上げ、Webサーバーなら実際にhttpリクエストを送信してレスポンスの検証等をするために、に依存するDBなど外部ミドルウェアのmockサーバーが起動されlistenするようにクライアントをバインドする構成が多いと思います。

example_e2e
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それぞれ下記のような構造のパッケージが記述されています。

lib1/lib1.go
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の順番に依存させるコードです。

main.go
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化するということです。
かなり単純化していますが、やりたいことはこういうことです。

e2d/e2d_test.go
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への接続オーバーヘッドが発生してしまう点です。

cmd/web.go
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へのオーバーヘッド予防を実現しています。

e2d/e2d_test.go
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メソッドで定義することができます。

wire/injector.go
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を末端として定義しています。

web/wire/injector.go
//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クライアント)を末端として取り扱うようにしています。

e2d/e2d_test.go
//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にしています。

e2d/e2d_test.go
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!

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?