✔️

GORMとgo-txdb(+Gin)で、DBを使ったテストを安全に実行する

2024/09/25に公開

この記事でわかること

go-txdbを使ってgormでDBの読み書きを行うテストを安全に実行できるようにします。
usecaseおよびHTTPハンドラでテストを実施します。

概要

DB読み書きを伴う自動テストを記述するとき、あるテストが別のテストで作成されたテストデータの影響を受けてテストが失敗してしまうことがあります。

go-txdbを使うと、テストケース(コネクション)ごとにテストデータを分離できるので、テストケース間でテストデータが汚染されることを気にする必要がなくなったり、テスト終了後にテストデータの削除を気にする必要がなくなります。

登場するライブラリ

  • gorm
    • golangのORM
  • go-txdb
    • テストケースごとにトランザクションを分離して実行するSQLドライバ
  • gin
    • golangのhttpルータ

テストの書き方について

コード

https://github.com/kuwa72/sample-gorm-txdb-testing

テストごとのDBコネクション作成とデータ投入

gormインスタンスを作成する前にtxdb.Registerを呼び出しておく。
テストごとにコネクションを作成してテストを実行する。

nameはテストデータを分離してほしい単位(今回はテスト関数ごと)でユニークにしておく。
sqlite.NewのDriverNameとしてnameを渡すとtxdb経由でDBコネクションが生成され、異なるnameとはデータが混在しなくなる。

txdb経由でコネクションを生成する例

func NewTestDB(name string) (*gorm.DB, error) {
    // nameはパッケージ全体でユニークにする。重複するとエラーとなる。
    // dsnはRDBMSごとのDSNを渡す(sqliteの場合はDBファイルへのパス)
	txdb.Register(name, "sqlite3", dsn)

    // コネクションを生成する際はDriverNameとしてnameを渡す
	dialector := sqlite.New(sqlite.Config{
		DriverName: name,
		DSN:        dsn,
	})
    // 生成したコネクションでgormインスタンスを作成
	db, err := gorm.Open(dialector, &gorm.Config{
		Logger: logger.Default.LogMode(logger.Info),
	})
	if err != nil {
		return nil, err
	}
    // コネクションを作成したらテーブル作成したり初期データを投入する
	initDB(db)
	return db, nil
}

各テストの頭で上記関数を使ってコネクションを生成する。
その際忘れずにdeferでdbを閉じること。
後は普通にテストを記述する。
テストが複数あってもテストデータは混ざらないし、テスト終了後にテスト中で作成したデータは削除される。

テストごとにユニークなnameでコネクションを作成する都合上、TestMainでコネクションを作成したりテストデータを投入はできないと思われる。

usecaseレベルのテスト例

必要事項はコード中のコメントとして書く

func TestLoginUser(t *testing.T) {
    // コネクションを作成して初期データが生成される
	db, _ := NewTestDB("TestLoginUser")
	defer func() {
        // *** 忘れずにcloseする ***
		db, _ := db.DB()
		db.Close()
	}()

    // テストごとのデータ投入。
	db.Create(
		&usecase.User{
			Name:     "test1",
			Email:    "test1@example.com",
			Password: "test1",
		},
	)

    // あとは普通にテストを実装する。
    // テスト結果の確認については、DBから直接値を取得してもよい。
	type args struct {
		email    string
		password string
	}
	tests := []struct {
		name    string
		args    args
		want    *usecase.User
		wantErr bool
	}{
		{
			name: "normal",
			args: args{
				email:    "test1@example.com",
				password: "test1",
			},
			want: &usecase.User{
				Name:     "test1",
				Email:    "test1@example.com",
				Password: "test1",
			},
			wantErr: false,
		},
		{
			name: "exists",
			args: args{
				email:    "test@example.com",
				password: "test",
			},
			want:    nil,
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert := assert.New(t)
			got, err := usecase.LoginUser(db, tt.args.email, tt.args.password)
			if (err != nil) != tt.wantErr {
				t.Errorf("LoginUser() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if tt.want == nil {
				assert.Nil(got)
				return
			}
			assert.Equal(got.Name, tt.want.Name)
			assert.Equal(got.Email, tt.want.Email)
			assert.Equal(got.Password, tt.want.Password)
		})
	}
}

HTTPルータを経由したテスト例

内部的にHTTPルータを起動しハンドラを呼び出してテストすることができる。
Ginでのやり方はTestingのページに記載されている。

普通にルータを起動し、テスト用のhttptest.NewRecorderhttp.NewRequestに渡すことでHTTPリクエストを呼び出し、結果を記録・検証できる。

func TestUserHandler_CreateUser(t *testing.T) {
    // ユニークなname(ここでは関数名)を渡す
	db, _ := testutil.NewTestDB("TestUserHandler_CreateUser")
	defer func() {
        // *** 忘れずにclose ***
		db, _ := db.DB()
		db.Close()
	}()

    // テストデータ作成
	db.Create(
		&usecase.User{
			Name:     "testname2",
			Email:    "test2@example.com",
			Password: "testpass2",
		},
	)

    // テスト用のコネクションを使ってルーターを起動する
	h := &handler.UserHandler{
		DB: db,
	}
	router := handler.SetupRouter()
	router = h.CreateUser(router)

	tests := []struct {
		name   string
		req    handler.CreateUserRequest
		status int
		want   usecase.User
	}{
		{
			name: "create success",
			req: handler.CreateUserRequest{
				Name:     "testname1",
				Email:    "test1@example.com",
				Password: "testpass1",
			},
			status: http.StatusOK,
			want: usecase.User{
				ID:       1,
				Name:     "testname1",
				Email:    "test1@example.com",
				Password: "testpass1",
			},
		},
		{
			name: "exists",
			req: handler.CreateUserRequest{
				Name:     "testname2",
				Email:    "test2@example.com",
				Password: "testpass2",
			},
			status: http.StatusOK,
			want:   usecase.User{},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert := assert.New(t)

            // テストケースごとにRecorderを作成して呼び出し
			w := httptest.NewRecorder()
			reqJson, _ := json.Marshal(tt.req)
			req, _ := http.NewRequest("POST", "/user/add", bytes.NewBuffer(reqJson))
			router.ServeHTTP(w, req)

            // httptest.Recorderを使って呼び出し結果を検証する
			assert.Equal(tt.status, w.Code)
			if tt.status == http.StatusOK {
				var got usecase.User
				json.Unmarshal(w.Body.Bytes(), &got)
				assert.Equal(tt.want.Name, got.Name)
				assert.Equal(tt.want.Email, got.Email)
				assert.Equal(tt.want.Password, got.Password)

                // DB中のデータを直接検証できる
				assert.NoError(db.Where("email = ?", tt.req.Email).First(&got).Error)
				assert.Equal(tt.req.Name, got.Name)
				assert.Equal(tt.req.Email, got.Email)
				assert.Equal(tt.req.Password, got.Password)
			}
		})
	}
}
株式会社エスマット

Discussion