はじめに
この記事は、Kyash Advent Calendar 2023 9日目の記事です。
こんにちは。Kyash でエンジニアをしている reo です。
今年は、開発基盤チームメンバーとしてTechTalkに登壇デビューしたり、新機能チームに入ってゴリゴリ開発したりあっという間の一年でした。
今回は日頃のユニットテストにおける認知負荷を下げて1、よりテストケースに集中できる方法を紹介します。
前提
この記事で扱うアーキテクチャは、DDD+オニオンアーキテクチャ+Repositoryパターンを想定しています。
掲載内容は私自身の見解であり、必ずしも所属する企業や組織の立場、戦略、意見を代表するものではありません。2
DBをモックすることの課題
きっかけ
アプリケーションレイヤーで Repository を介した DB アクセスのテストを書くときに、mock を使っていました。
公式からmockがアーカイブされたのも今年6月でした。
これからmockを使う場合は、uber-go/mockを使います。
テストコードはこんなかんじ
func TestGreatestUsecase(t *testing.T) {
tests := map[string]struct {
args args
setup func(args args, mock mock) (*gomock.Controller, *greatestUsecase)
want want
}{
"when greatest test case": {
args: args{
ctx: context.Background(),
input: &GreatUsecaseInput{
Something: "師走",
},
},
// NOTE: モックのための関数を用意する
setup: func(ctrl *gomock.Controller, args args) greatestUsecase {
// NOTE: DBアクセスの発生するRepositoryをmockする
greatestRepository := NewGreatestRepository(ctrl)
greatestRepository.EXPECT().
Find(args.ctx, args.input.Something).
Return(greatestRepositoryOutput, greatestRepositoryErr).
Times(greatestRepositoryTimes)
// NOTE: ここにアプリケーションレイヤで使うRepositoryを渡す必要がある
greatestUsecase := &greatestUsecase{
greatestRepository: greatestRepository,
}
return ctrl, greatestUsecase
},
want: want{
output: &GreatestUsecaseOutput{
Something: "師走",
},
},
},
}
for casename, tt := range tests {
casename, tt := casename, tt
t.Run(casename, func(t *testing.T) {
t.Parallel()
// given
ctrl := gomock.NewController(t)
greatestUsecase := tt.setup(ctrl, tt.args)
defer ctrl.Finish()
// when
got := greatestUsecase.Do(tt.args.ctx, tt.args.input)
// then
if !reflect.DeepEqual(got.Something, tt.want.output.Something) {
t.Errorf("got: %+v\nwant %+v\n", got.Something, tt.want.output.Something)
}
})
}
}
このテストコードから、以下の課題を感じていました。
課題1: モックするためのsetup
関数をテストケースごとに用意している
例えば境界値テストのような、ほぼセットアップする内容が似通ったテストケースを作成するたびに、同じような setup
関数を用意するのは、開発コストが大きいと感じています。
この課題を解決するために、setup
関数をテストケース間で共有して、テストケースごとに引数を渡す方法が考えられます。
イメージとしては、こんなかんじ
tests := map[string]struct {
args args
greatestRepositoryMock greatestRepositoryMock
want want
}{
args: args{
ctx: context.Background(),
input: &GreatUsecaseInput{
Something: "師走",
},
},
greatestRepositoryMock: {
Output: GratestRepositoryOutput{
ID: 1,
Something: "師走"
},
Err: nil,
}
want: want{
output: &GreatestUsecaseOutput{
Something: "師走",
},
},
}
この方法ならsetup
関数を量産する手間は省けますが、args
とwant
のほかに、mockの想定しているOutput
を用意しなければいけません。
また、この例ではRepositoryが1つですが、実際のRepositoryは複数あることがほとんどなので、コード量は更に増えます。
課題2: テーブル駆動テストの旨味を活かせてない
テーブル駆動テストは「テーブルとテストの部分でデータとロジックを分離する」ことで認知負荷を下げることができます。3
データとロジックを分離することで、テストケース網羅の視認性がよくなることに加えて、新しいテストケースの追加が容易になります。
よいテーブル駆動テストについては、以下の記事も参考にさせていただきました。
以上を踏まえて、setup
関数はテーブルにロジックを持たせているため、新しいテストを容易に追加するという観点では良いアプローチではないかもしれないと感じています。
課題3: モックはあくまで想定している値しか返さない
setup
関数で挙げたように、想定しているOutput
やErr
を用意できることがモックの旨味だと思います。
しかし、今回取り上げているモック対象はDBです。
実際にクエリを流してみないと分からない重複キーエラーなどを捕捉するためには、開発環境でアプリケーションを起動してみて分かることも少なくありません。(モックを使わずに、テストDBが用意できている場合を除く)
以上の課題を踏まえて、モックに代わる方法を提示します。
ory/dockertest について
voluntas さんの記事を見て、いつかやるぞと温めていました。
サンプルコードは、Kyashの技術スタックと合わせて PostgreSQL + Gorm(ORM)で進めます。
サンプル
アプリケーション構成
アプリケーションレイヤのみ抽出しています。
tree -L 2
.
├── db
│ ├── schema.sql // DDL
│ └── usecase.sql // usecase ごと
├── usecase.go
└── usecase_test.go
バージョン
- Docker Desktop:
4.25.2 (129061)
- Go:
1.21
- github.com/ory/dockertest:
v3.10.0
- gorm.io/gorm:
v1.24.6
- gorm.io/driver/postgres:
v1.4.6
サンプルコード
公式のexampleを参考にしています
https://github.com/ory/dockertest/blob/v3/examples/PostgreSQL.md
// NOTE: dockertestインスタンスのコネクション
var gormDB *gorm.DB
func TestMain(m *testing.M) {
// uses a sensible default on windows (tcp/http) and linux/osx (socket)
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("Could not construct pool: %s", err)
}
err = pool.Client.Ping()
if err != nil {
log.Fatalf("Could not connect to Docker: %s", err)
}
// NOTE: dbディレクトリ配下のファイル名のリストを取得する
var mountFiles []string
pwd, _ := os.Getwd()
files, err := os.ReadDir(pwd + "/db")
if err != nil {
log.Fatalf("Could not read db directory: %s", err)
}
// NOTE: docker-entrypoint-initdb.d にマウントすることで、コンテナ起動時にSQLを実行してくれる
for _, file := range files {
mountFiles = append(mountFiles, pwd+"/db/"+file.Name()+":/docker-entrypoint-initdb.d/"+file.Name())
}
// pulls an image, creates a container based on it and runs it
resource, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "postgres",
Tag: "11",
Env: []string{
"POSTGRES_PASSWORD=secret",
"POSTGRES_USER=user_name",
"POSTGRES_DB=dbname",
"listen_addresses = '*'",
},
Mounts: mountFiles,
}, func(config *docker.HostConfig) {
// set AutoRemove to true so that stopped container goes away by itself
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
})
if err != nil {
log.Fatalf("Could not start resource: %s", err)
}
hostAndPort := resource.GetHostPort("5432/tcp")
databaseUrl := fmt.Sprintf("postgres://user_name:secret@%s/dbname?sslmode=disable", hostAndPort)
log.Println("Connecting to database on url: ", databaseUrl)
resource.Expire(120) // Tell docker to hard kill the container in 120 seconds
var db *sql.DB
// exponential backoff-retry, because the application in the container might not be ready to accept connections yet
pool.MaxWait = 120 * time.Second
if err = pool.Retry(func() error {
db, err = sql.Open("postgres", databaseUrl)
if err != nil {
return err
}
return db.Ping()
}); err != nil {
log.Fatalf("Could not connect to docker: %s", err)
}
gormDB, err = gorm.Open(postgres.New(postgres.Config{Conn: db}), &gorm.Config{})
if err != nil {
log.Fatalf("Could not create gorm DB from dockertest sql connection: %s", err)
}
//Run tests
code := m.Run()
// You can't defer this because os.Exit doesn't care for defer
if err := pool.Purge(resource); err != nil {
log.Fatalf("Could not purge resource: %s", err)
}
os.Exit(code)
}
解説
TestMain
でdockertestインスタンスに加え、マイグレーションしたいクエリのセットアップを行います。
あとは、各テストメソッドで、dockertestインスタンスによるDBコネクション(gormDB
)をつかうことができます。
テストコード
func TestGreatestUsecase(t *testing.T) {
tests := map[string]struct {
input GreatestUsecaseInput
want GreatestUsecaseOutput
}{
"when greatest usecase": {input: GreatestUsecaseInput{Something: "師走"}, want: GreatestUsecaseInput{Something: "師走"}},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
var output GreatestUsecaseOuntput
greatestUsecase := NewGreatestUsecase(repository.NewGreatestRepository())
ctx := context.Background()
// NOTE: テストケースごとにトランザクションを開始する
tx := NewTransaction(gormDB)
_ = tx.DoInTx(ctx, func(ctx context.Context) error {
output = greatestUsecase.Do(ctx, tc.input)
if !reflect.DeepEqual(output, GreatestUsecaseOuntput{}) {
t.Fatalf("want: %v, got: %v", tc.want, output)
}
// NOTE: テストケースごとに Rollback する
return errors.New("rollback")
})
})
}
}
解説
実際のDBと同様にクエリを実行できるので、テストケースのブロックからも分かる通り、RepositoryのOutput
をケース内部で用意する必要はありません。
また、アプリケーションレイヤのメソッド(greatestUsecase.Do
)のinput
とwant
のみケースに書くため、テストケースの追加容易性が上がり網羅性に集中できるようになりました。
まとめ
普段感じていたDBをモックすることについての課題を共有させていただきました。
課題を踏まえて、ory/dockertestはテストDBを用意することなく、エフェメラルにDBをエミュレートできるのが推しポイントです。
既存のDBモックを使ったテストの認知負荷を下げる取り組みとして試してみてはいかがでしょうか。
補足:モックを使わないというわけではない
課題を感じているのはあくまでもDBインスタンスをモックすることのみです。
外部APIを模したテストでは、uber-go/mock含めモックライブラリが選択肢に挙がると思います。
-
今年社内の読書会で読んだチームトポロジー 価値あるソフトウェアをすばやく届ける適応型組織設計にある「認知負荷を最小にする」というフレーズが気に入って、zoom の背景画像に刻みました。 ↩
-
今年参加させていただいたナレッジワーク社のトレーニングで学びました。
https://note.com/knowledgework/n/n4d7b97ff802c ↩