【2021JDSCアドベントカレンダー】永続化処理を伴うusecase層のテストコード実装変遷@golang with DDD
アドベントカレンダー日程表
こちら日程表になります。他記事にも飛べますのでぜひご覧くださいませ!
https://note.com/jdsc/n/nfc811d73c70c
はじめに
こんばんは。
29日から年末休暇に入っている山口です。
JDSCではエンジニアとして主にdemand insightのバックエンドの開発を行っています。
demand insightのバックエンドは、ドメイン駆動設計を取り入れGolangで実装されています。
今日は表題の通り、永続化処理を伴うusecase層(Application層)のテストを改善していったのか、順に紹介したいと思います。
この記事では、以下のファイルが登場します。
domain/repository.go
ドメイン層にrepositoryのinterfaceを定義したもの
repository/mock/repository.go
domain/repository.goのinterfaceを実装したmockRepository
repository/postgres/repository.go
domain/repository.goのinterfaceを実装したもの
実際にDBとコミュニケーションを行う
※ demand insightではRDBMSにはPostgreSQLを採用、Driverはpgxを使っています
usecase.go
いわゆるユースケースが記載される
本記事では「商品を登録する」ユースケース
usecase_test.go
テストコードを記載
初期
初期は以下のように実装していました。
domain/repository.go
type Repository interface {
SaveProducts(ctx content.Context, product Product) error
}
repository/mock/repository.go
type repository struct{}
func NewRepository() domain.Repository {
return &repository{}
}
func(r *repository) SaveProduct(ctx content.Context, product Product) error {
return nil
}
repository/postgres/repository.go
type repository struct{
dbpool *pgxpool.Pool
}
func NewRepository(dbpool *pgxpool.Pool) domain.Repository {
return repository{dbpool: dbpool}
}
func(r *repository) SaveProduct(ctx content.Context, product Product) error {
err := r.dbpool.Exec(ctx, updateSQL, /* 以降の引数は省略 */ )
if err != nil {
return domain.ErrCanNotSaveProduct
}
return nil
}
usecase.go
type usecase struct {
ctx context.Context
repository domain.Repository
}
func NewUsecase(ctx context.Context, repository domain.Repository) {
return useCase{ctx: ctx, repository: repository}
}
func (u usecase) RegisterProduct(janCode string, price int32) error {
product, err := domain.NewProduct(janCode, price)
if err != nil {
// Productの仕様を満たさない場合はエラーが返される
return err
}
err := u.repository.SaveProduct(u.ctx, product)
if err != nil {
return err
}
return nli
}
usecase_test.go
func TestRegisterProduct(t *testing.T) {
// success case
t.Run("valid product parameters", func(t *testing.T) {
repository := mock.NewRepository()
usecase := NewUsecase(context.Background(), repository)
err := usecase.RegisterProduct("450000000001", 1280)
assert.Nil(t, err)
})
// failure case
t.Run("invalid product parameters", func(t *testing.T) {
repository := mock.NewRepository()
usecase := NewUsecase(context.Background(), repository)
err := usecase.RegisterProduct("450000000001", -500)
assert.NotNil(t, err)
})
}
この実装では「repositoryが実際に呼ばれたのか」、 「repositoryに渡された引数は意図したものであるのか」を確認できておらず、不安が残る状態です。
この不安を解消するために、mock.repositoryではなく、postgres.repositoryを使うように変更し、より厳密にテストをするようしました。
mockRepositoryからpostgresRepositoryへの切り替え
変更後のテストコードは以下になります。
usecase_test.go
func TestRegisterProduct(t *testing.T) {
ctx := context.Background()
dbpool, _ := pgxpool.Connect(ctx, os.Getenv("DB_DSN"))
defer dbpool.Close()
// success case
t.Run("valid product parameters", func(t *testing.T) {
resetData() // DBをリセットする
repository := postgres.NewRepository(dbpool)
usecase := NewUsecase(context.Background(), repository)
err := usecase.RegisterProduct("450000000001", 1280)
assert.Nil(t, err)
// DBから保存されたオブジェクトを復元してチェック
product, err := repository.FetchProduct("450000000001")
assert.Nil(t, err)
assert.Equal(t, "450000000001", product.JanCode())
assert.Equal(t, 1280, product.Price())
})
// failure case
t.Run("invalid product parameters", func(t *testing.T) {
resetData() // DBをリセットする
repository := postgres.NewRepository(dbpool)
usecase := NewUsecase(context.Background(), repository)
err := usecase.RegisterProduct("450000000001", -500)
assert.NotNil(t, err)
// DBに保存されていないことをチェック
product, err := repository.FetchProduct("450000000001")
assert.NotNil(t, err)
assert.Equal(t, domain.Product{}, product)
})
}
この修正で、初期の実装にあった不安は解消されました。
しかし、以下の点からusecaseのテストとしては非常に壊れやすくなったように思えます。
・postgres.repositoryへの依存が強く、SaveProductやFetchProductが壊れた場合、usecaseも壊れてしまう
・永続化できること、復元できることをチェックしている実装となっており、repositoryのテストの責務と被ってしまう
・DBのリセットを忘れたりした場合に、他のテストへの影響が発生する(これは何が発生したのか理解することが難しくなる)
もっと良い方法はないのだろうか。と思い手にとった本が「ドメイン駆動設計 サンプルコード&FAQ」でした。
この本では、mockでメソッドの引数をキャプチャし、それを検証する方法が紹介されており、「なるほど。」と思わず声が出た瞬間でした。
早速このやり方を取り入れてみます。
mockRepositoryで引数をキャプチャし検証する
repository/mock/repository.go
// repositoryを公開に変更 -- ①
type Repository struct{
// 引数を格納するメンバを定義
SaveProductArgs struct{
Product domain.Product
}
}
func NewRepository() domain.Repository {
return &Repository{}
}
func(r *Repository) SaveProduct(ctx content.Context, product Product) error {
// 引数をキャプチャする
r.SaveProductArgs.Product = product
return nil
}
usecase_test.go
func TestRegisterProduct(t *testing.T) {
// success case
t.Run("valid product parameters", func(t *testing.T) {
repository := mock.NewRepository()
usecase := NewUsecase(context.Background(), repository)
err := usecase.RegisterProduct("450000000001", 1280)
assert.Nil(t, err)
mock := repository.(*mock.Repository) // 型アサーションでdomain.Repository型からmock.Repository型に変換 -- ②
// キャプチャしたProductを検証 -- ③
assert.Equal(t, "450000000001", mock.SaveProductArgs.Product.JanCode())
assert.Equal(t, 1280, mock.SaveProductArgs.Product.Price())
})
// failure case
t.Run("invalid product parameters", func(t *testing.T) {
repository := mock.NewRepository()
usecase := NewUsecase(context.Background(), repository)
err := usecase.RegisterProduct("450000000001", -500)
assert.NotNil(t, err)
mock := repository.(*mock.MockRepository) // 型アサーションでdomain.Repository型からmock.Repository型に変換
assert.Equal(t, domain.Product{}, mock.SaveProductArgs.Product)
})
}
domein.Repository型ではmock.Repositoryのメンバにアクセスできないため、
②でdomain.Repository型をmock.Repository型に変換しています。
これを行うためには、usecaseのパッケージからmockパッケージのrepositoryが参照可能となるため①で公開に変更しています。
そして、③の処理で実際にキャプチャされた引数を検証しています。
これにより、postgres.repositoryへの依存はなくなり、壊れにくいテストになった上、初期実装では担保できていなかった「repositoryが実際に呼ばれたのか」、 「repositoryに渡された引数は意図したものであるのか」が担保できるようになりました。
以上がusecase層のテストコード実装の変遷になります。
終わりに
demand insightだけでなく、他のプロダクトでもドメイン駆動設計が取り入れ開発が進んでいます。
ドメイン駆動設計でプロダクトを作っていきたいと考えられているアプリケーションエンジニアの方、JDSCでは一緒に働きませんか?応募待っています!カジュアル面談から是非!
ちょっと興味が湧いたかも。とうかた方は是非、以下のページからエントリーお願いします!
https://jdsc.ai/recruit/