0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

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

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

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

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

シリーズ一覧

  1. GoでTodoアプリを作ってみた(Gin × GORM × Docker)
  2. クリーンアーキテクチャ風味でGo!なTodoアプリ開発
  3. GoでWebアプリのテストを書く(txdb × gomock × testify)

リポジトリ

開発環境

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による個別トランザクションによって、開発時データやテストケース間でデータが混在しないようにしています。

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

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で接続を開く際、下記のように値を渡すことにより、各テストケース間でデータが分離されるようです。

  • DriverNametxdb.Registerで登録したドライバ名
  • DSN:重複しない値

参考記事

モックを使用したテスト

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

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

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

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

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

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を生成する
  2. m.EXPECT().~で期待する関数の呼び出しと戻り値を設定する
  3. 参照するオブジェクトにモックを渡す
  4. テストしたい関数を実行する

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

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

テスト時に遭遇した問題

テスト実行時に、HTMLの読み込みでエラーになる

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

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

テスト実行時に、POSTリクエストに対するリダイレクトで200が返ってくる

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

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

期待通りのケースとそうじゃないケース
// 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")
}

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

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

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

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

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を呼び出します。

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公式のテストコードでも同じように対処されているので、それに倣いましょう。

おわりに

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

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

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

参考資料

  1. GETリクエストでも、Content-Typeがヘッダーにある場合は同様らしいですが

  2. 本番で問題ないのか? と思われる方もいらっしゃるかもしれませんが、本番だと処理の際にcontext.Writer.WriteHeaderNowが呼ばれているようです : https://github.com/gin-gonic/gin/blob/6a0556ed5a67d1d12ae3e7ea2c0121b6c3b894e2/gin.go#L621

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?