LoginSignup
3
1

ory/dockertest で網羅性を取り戻す

Posted at

はじめに

この記事は、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関数を量産する手間は省けますが、argswantのほかに、mockの想定しているOutputを用意しなければいけません。
また、この例ではRepositoryが1つですが、実際のRepositoryは複数あることがほとんどなので、コード量は更に増えます。

課題2: テーブル駆動テストの旨味を活かせてない

テーブル駆動テストは「テーブルとテストの部分でデータとロジックを分離する」ことで認知負荷を下げることができます。3
データとロジックを分離することで、テストケース網羅の視認性がよくなることに加えて、新しいテストケースの追加が容易になります。

よいテーブル駆動テストについては、以下の記事も参考にさせていただきました。

以上を踏まえて、setup関数はテーブルにロジックを持たせているため、新しいテストを容易に追加するという観点では良いアプローチではないかもしれないと感じています。

課題3: モックはあくまで想定している値しか返さない

setup 関数で挙げたように、想定しているOutputErrを用意できることがモックの旨味だと思います。
しかし、今回取り上げているモック対象は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)のinputwantのみケースに書くため、テストケースの追加容易性が上がり網羅性に集中できるようになりました。

まとめ

普段感じていたDBをモックすることについての課題を共有させていただきました。
課題を踏まえて、ory/dockertestはテストDBを用意することなく、エフェメラルにDBをエミュレートできるのが推しポイントです。

既存のDBモックを使ったテストの認知負荷を下げる取り組みとして試してみてはいかがでしょうか。

補足:モックを使わないというわけではない

課題を感じているのはあくまでもDBインスタンスをモックすることのみです。
外部APIを模したテストでは、uber-go/mock含めモックライブラリが選択肢に挙がると思います。

  1. 今年社内の読書会で読んだチームトポロジー 価値あるソフトウェアをすばやく届ける適応型組織設計にある「認知負荷を最小にする」というフレーズが気に入って、zoom の背景画像に刻みました。

  2. https://xn--u9jy52g42am02luma.jp/

  3. 今年参加させていただいたナレッジワーク社のトレーニングで学びました。
    https://note.com/knowledgework/n/n4d7b97ff802c

3
1
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
3
1