GoでWebアプリのテストを書く（txdb × gomock × testify）

# はじめに
どうも、水無月せきなです。
この記事は、Goで開発したTodoアプリの技術解説をするシリーズの最終回です。

今回は「テスト編」として、実際に行った単体テストの内容を紹介します。

- txdbでデータを分離する
- gomockで生成したモックを使ってハンドラーのテストを書く
- GinのContextを使ったテストの注意点

といったテストに関する実践例を記載していますので、ぜひ最後までご覧下さい。

# シリーズ一覧

1. [GoでTodoアプリを作ってみた（Gin × GORM × Docker）](https://qiita.com/MinadukiSekina/items/4d5036721e0074018f14)
1. [クリーンアーキテクチャ風味でGo！なTodoアプリ開発](url)
1. GoでWebアプリのテストを書く（txdb × gomock × testify）

## リポジトリ
https://github.com/MinadukiSekina/todo-go-app

## 開発環境
Windows 10　22H2
WSL2
Docker Desktop 4.42.0
Cursor 1.1.3
Go 1.24.4
MySQL 8.0

# テストについて
## DBが絡むテスト
DBに対してデータの操作を行う部分（`app/db/todoRepository.go`）のテストは、テスト用のデータベースと`txdb`による個別トランザクションによって、開発時データやテストケース間でデータが混在しないようにしています。

また、`testify`の`suite`パッケージを使用して、共通で行いたい処理をまとめたりしています。

```go:app/db/todoRepository_test.go
package db

import (
	"bytes"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"testing"

	"github.com/DATA-DOG/go-txdb"
	"github.com/MinadukiSekina/todo-go-app/app/domain/models"
	"github.com/google/uuid"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/suite"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

// テスト用のDBハンドラー
type testHandler struct {
	conn *gorm.DB
}

// SqlHandlerインターフェイスの実装
func (th *testHandler) GetConnection() *gorm.DB {
	return th.conn
}

// Closerハンドラーの実装
func (th *testHandler) Close() error {
	return nil
}

// テストスイートの構造体
type todoRepositoryTestSuite struct {
	suite.Suite
	dbConn *gorm.DB
}

// テストスイートを実行する
func TestTodoRepositoryTestSuite(t *testing.T) {
	suite.Run(t, new(todoRepositoryTestSuite))
}

// テストスイートの実行前に処理される
func (s *todoRepositoryTestSuite) SetupSuite() {
	// db.envに定義したDB関係の環境変数を取得
	dbUser := os.Getenv("MYSQL_USER")
	dbPassword := os.Getenv("MYSQL_PASSWORD")
	dbName := os.Getenv("TEST_DATABASE")
	// tcp（）の中にdocker-composeで定義したDB用コンテナのサービス名を入れれば、
	// 自動的にホストとポートを読み取ってくれる
	dsn := fmt.Sprintf(
		"%s:%s@tcp(db)/%s?charset=utf8mb4&parseTime=true&loc=Local",
		dbUser,
		dbPassword,
		dbName,
	)

	// txdbに登録する
	txdb.Register("txdb", "mysql", dsn)

	// マイグレーションは一度だけ実行
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		s.Failf("failed to connect to database", "%v", err)
	}
	if err := db.AutoMigrate(&models.Todo{}); err != nil {
		s.Failf("failed to migrate database", "%v", err)
	}
	sqlDB, err := db.DB()
	if err != nil {
		s.Failf("failed to get underlying sql.DB", "%v", err)
	}

	// マイグレーション用の接続を閉じる
	defer sqlDB.Close()
}

// 各テスト終了時にDB接続を閉じる
func (s *todoRepositoryTestSuite) Close() {
	// テスト用に開いた接続を閉じる
	sqlDB, err := s.dbConn.DB()
	if err != nil {
		s.Failf("failed to get underlying sql.DB", "%v", err)
	}
	defer sqlDB.Close()
}

…省略…

func (s *todoRepositoryTestSuite) TestFindById() {

	todo1 := models.Todo{Title: "test1", Status: models.NotStarted}
	todo2 := models.Todo{Title: "test1", Status: models.NotStarted}
	todo2.ID = 1

	cases := map[string]struct {
		want      *models.Todo
		expectErr bool
		err       error
		setup     func(*gorm.DB)
	}{
		"正常ケース:指定したIDのデータあり": {
			want:      &todo1,
			expectErr: false,
			err:       nil,
			setup:     func(d *gorm.DB) { _ = d.Create(&todo1) },
		},
		"異常ケース:指定したIDのデータが無い": {
			want:      &todo2,
			expectErr: true,
			err:       errors.New("record not found"),
			setup:     func(d *gorm.DB) {},
		},
	}
	for name, tt := range cases {
		s.T().Run(name, func(t *testing.T) {
			// テスト用DBに接続する
			db, err := gorm.Open(mysql.New(mysql.Config{DSN: uuid.NewString(), DriverName: "txdb"}))
			if err != nil {
				s.Failf("database connection is not established", "%v", err)
			}
			// コネクションを格納する
			s.dbConn = db

			defer s.Close()

			// セットアップ関数を実行する
			tt.setup(s.dbConn)

			// 初期処理
			sqlHandler := testHandler{conn: s.dbConn}
			todoRepository := NewTodoRepository(&sqlHandler)

			todo, err := todoRepository.FindById(tt.want.ID)

			// 結果を確認
			if tt.expectErr {
				if assert.Error(t, err) {
					assert.Equal(t, tt.err, err)
				}
				assert.Nil(t, todo)
			} else {
				if assert.NoError(t, err) {
					assert.Equal(t, *tt.want, *todo)
				}
			}
		})
	}
}

…省略…

```

`gorm.Open`で接続を開く際、下記のように値を渡すことにより、各テストケース間でデータが分離されるようです。

* `DriverName`：`txdb.Register`で登録したドライバ名
* `DSN`：重複しない値

**参考記事**

https://tech.buysell-technologies.com/entry/adventcalendar2021-12-02#DB%E3%81%AE%E3%83%86%E3%82%B9%E3%83%88%E3%82%92%E3%81%97%E3%81%9F%E3%81%84

## モックを使用したテスト

`gomock`を導入したので、モックを生成してテストを書いてみました。

インターフェースを定義したファイルへのパスを`mockgen`コマンドの`-source`オプションで渡すと、それを元にモックを生成してくれます。

また、`destination=`を指定しないと標準出力に出るようなので、適宜指定しましょう。指定した場合、ファイルは勝手に作られます。

```bash:モック生成コマンドの例
mockgen -source=app/domain/repository/todoRepository.go -destination=app/mock/repository/mockTodoRepository.go
```

例示したコマンド内のパスは、プロジェクトで適当なパスに置き換えてください。
他にもオプションはあるようなので、詳しくは下記リポジトリのREADMEもご参照ください。

https://github.com/uber-go/mock

```go:app/handlers/web/todoHandler_test.go
package handlers

import (
	"bytes"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"github.com/MinadukiSekina/todo-go-app/app/domain/models"
	mock_usecases "github.com/MinadukiSekina/todo-go-app/app/mock/usecase"
	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"go.uber.org/mock/gomock"
)

func TestIndex(t *testing.T) {

	gin.SetMode(gin.TestMode)

	todo1 := models.Todo{Title: "test1", Status: models.NotStarted}
	todo1.ID = 1

	todo2 := models.Todo{Title: "test2", Status: models.NotStarted}
	todo2.ID = 1

	nothingTodos := []models.Todo{}
	onlyOneTodos := []models.Todo{todo1}
	manyHasTodos := []models.Todo{todo1, todo2}

	cases := map[string]struct {
		prepareMockFn func(m *mock_usecases.MockTodoUsecase)
		want          int
	}{
		"正常ケース:データなし": {
			prepareMockFn: func(m *mock_usecases.MockTodoUsecase) { m.EXPECT().Show().Return(&nothingTodos, nil) },
			want:          http.StatusOK,
		},
		"正常ケース:1件データあり": {
			prepareMockFn: func(m *mock_usecases.MockTodoUsecase) { m.EXPECT().Show().Return(&onlyOneTodos, nil) },
			want:          http.StatusOK,
		},
		"正常ケース:2件データあり": {
			prepareMockFn: func(m *mock_usecases.MockTodoUsecase) { m.EXPECT().Show().Return(&manyHasTodos, nil) },
			want:          http.StatusOK,
		},
		"異常ケース:エラーあり": {
			prepareMockFn: func(m *mock_usecases.MockTodoUsecase) {
				m.EXPECT().Show().Return(nil, errors.New("something is wrong"))
			},
			want: http.StatusInternalServerError,
		},
	}

	for name, tt := range cases {
		t.Run(name, func(t *testing.T) {

			// モックの呼び出しを管理するControllerを生成
			mockCtrl := gomock.NewController(t)
			defer mockCtrl.Finish()

			// モックの生成
			mock := mock_usecases.NewMockTodoUsecase(mockCtrl)

			// テスト中に呼ばれるべき関数と帰り値を指定
			tt.prepareMockFn(mock)

			// gin contextの生成
			w := httptest.NewRecorder()
			c, r := gin.CreateTestContext(w)

			// テンプレートの読み込み
			// route.goと同じ指定だとエラーになったため、appからのパスで指定する
			r.LoadHTMLGlob("/app/app/templates/*/*.html")

			// リクエストを設定
			req, _ := http.NewRequest("GET", "/todo", nil)
			c.Request = req

			// mockを利用してテストする
			handler := NewTodoHandler(mock)
			handler.Index(c)

			// 結果を確認
			assert.Equal(t, tt.want, w.Code)
		})
	}
}

…省略…

```

`gomock`の基本的な使い方としては、

1. `gomock.NewController(t)`でモックの呼び出しを管理するControllerを生成する
1. `m.EXPECT().~`で期待する関数の呼び出しと戻り値を設定する
1. 参照するオブジェクトにモックを渡す
1. テストしたい関数を実行する

といった感じです。
`m.EXPECT().~`で設定した引数と、呼び出し時の引数が異なる場合もエラーになるようです。

また、テストコードの実装においては、`m.EXPECT().~`で期待する戻り値や、そもそも呼び出すかどうかも異なる場合がありました。解決策として、テストケースの条件にモックの動作を設定する関数（`prepareMockFn func(m *mock_usecases.MockTodoUsecase)`の部分）を定義しています。

# テスト時に遭遇した問題

## テスト実行時に、HTMLの読み込みでエラーになる
[モックを使用したテスト](#モックを使用したテスト)に記載したコード内のコメントにもありますが、テスト実行時にHTMLの読み込みでエラーになることがありました。

テストコードと実装の階層は同じなのですが、テスト実行時の場所がどうやら異なるようです。
今回の対応策としては、テストコード上のパスを修正しています。

## テスト実行時に、POSTリクエストに対するリダイレクトで200が返ってくる
POSTリクエストに対するテストを実行した際、ステータスコードは`302`などの300番台を期待しているのに、実際の応答として`200`が返ってくるということがありました。

不思議なのが、GETリクエストに対しては期待通りのステータスコードが返ってくる点です。

```go:期待通りのケースとそうじゃないケース
// router.GET("/todo/:id", th.ShowById)
func (th *TodoHandler) ShowById(c *gin.Context) {
	id_s := c.Param("id")
	id, err := strconv.ParseUint(id_s, 10, 64)
	if err != nil {
		SetFlashMessage(c, resultIsError, "このタスクは閲覧できません。")
        // ここのリダイレクトは、設定しているステータスコードがテスト実行時に返ってくる
		c.Redirect(http.StatusSeeOther, "/todo")
		return
	}
	…省略…
}

// router.POST("/todo", th.Create)
func (th *TodoHandler) Create(c *gin.Context) {
	…省略…
    // ここのリダイレクトは、テスト実行時に200が返ってくる
	c.Redirect(http.StatusFound, "/todo")
}
```

調べると、ドンピシャな質問がありました。

https://stackoverflow.com/questions/76319196/unit-testing-of-gins-context-redirect-works-for-get-response-code-but-fails-for

> This is a corner case of using the gin context returned from gin.CreateTestContext.

引用した通りなのですが、`gin.CreateTestContext`によって得た`gin.context`を使用した際の特殊なケースに遭遇したようです。

ここで、テストコードを見てみましょう。

```go:app/handlers/web/todoHandler_test.go
…省略…

func TestCreate(t *testing.T) {

	gin.SetMode(gin.TestMode)

	// テスト用の引数を格納する
	type args struct {
		title string
	}

	cases := map[string]struct {
		prepareMockFn func(m *mock_usecases.MockTodoUsecase)
		args          args
		want          int
	}{
		// 省略:テストケースを記載
	}

	for name, tt := range cases {
		t.Run(name, func(t *testing.T) {

			// 省略:モックの生成と設定
            
			// gin contextの生成
			w := httptest.NewRecorder()
			c, r := gin.CreateTestContext(w)

			// 省略:モックを使用しての実行と結果の確認
		})
	}
}
```

はい。上に示した通り、`gin.CreateTestContext`を使用しています。

`gin.CreateTestContext`を使用しているかつPOSTリクエストの場合[^1]は、レスポンスにリダイレクトのステータスコードを書き込むタイミングが無いようです[^2]。

じゃあどうするの？というところですが、`context.Redirect`を呼んだ後に`context.Writer.WriteHeaderNow`を呼び出します。

```go:app/handlers/web/todoHandler_test.go
func TestCreate(t *testing.T) {

	gin.SetMode(gin.TestMode)

	// テスト用の引数を格納する
	type args struct {
		title string
	}

	cases := map[string]struct {
		prepareMockFn func(m *mock_usecases.MockTodoUsecase)
		args          args
		want          int
	}{
		// 省略:テストケースを記載
	}

	for name, tt := range cases {
		t.Run(name, func(t *testing.T) {

			// 省略:モックの生成と設定

			// gin contextの生成
			w := httptest.NewRecorder()
			c, r := gin.CreateTestContext(w)

			// 省略:フォームデータを用意したりリクエストを設定

			// mockを利用してテストする
			handler := NewTodoHandler(mock)
			handler.Create(c)

			// ここを追加！！
			c.Writer.WriteHeaderNow()

			// 結果を確認
			assert.Equal(t, tt.want, w.Code)
		})
	}
}
```

上記のコードだと、`handler.Create(c)`の内部で`context.Redirect`を呼んでいるため、その後に`context.Writer.WriteHeaderNow`を呼び出します。

[Gin公式のテストコード](https://github.com/gin-gonic/gin/blob/6a0556ed5a67d1d12ae3e7ea2c0121b6c3b894e2/context_test.go#L1115-L1127)でも同じように対処されているので、それに倣いましょう。

# おわりに
ここまでお読みいただき、ありがとうございました。

リダイレクトのステータスコードが返ってこない問題は沼にハマる一歩手前だったのですが、自己解決できて良かったです。

この記事を含め、本シリーズの記事が参考になれば幸いです。

# 参考資料

* [Go 言語 testing チートシート](https://qiita.com/nirasan/items/b357f0ad9172ab9fa19b)
* [Golangのtestify/assert 使えそうな関数まとめ](https://qiita.com/JpnLavender/items/21b4574a7513472903ea)
* [gomockを完全に理解する](https://zenn.dev/sanpo_shiho/articles/01da627ead98f5)
* [GoでRubyみたいに単体テストを書きたくてやったこと](https://tech.buysell-technologies.com/entry/adventcalendar2021-12-02)
* [DATA-DOG/go-txdbでDB接続を含むテストを楽に書こう](https://zenn.dev/rinchsan/articles/83cf6f3b5d70c4d9b5d4)
* [GORMとgo-txdb(+Gin)で、DBを使ったテストを安全に実行する](https://zenn.dev/smartshopping/articles/b5eae9f64871be)

[^1]:GETリクエストでも、`Content-Type`がヘッダーにある場合は同様らしいですが
[^2]:本番で問題ないのか？ と思われる方もいらっしゃるかもしれませんが、本番だと処理の際に`context.Writer.WriteHeaderNow`が呼ばれているようです : https://github.com/gin-gonic/gin/blob/6a0556ed5a67d1d12ae3e7ea2c0121b6c3b894e2/gin.go#L621