はじめに
どうも、水無月せきなです。
この記事は、Goで開発したTodoアプリの技術解説をするシリーズの最終回です。
今回は「テスト編」として、実際に行った単体テストの内容を紹介します。
- txdbでデータを分離する
- gomockで生成したモックを使ってハンドラーのテストを書く
- GinのContextを使ったテストの注意点
といったテストに関する実践例を記載していますので、ぜひ最後までご覧下さい。
シリーズ一覧
- GoでTodoアプリを作ってみた(Gin × GORM × Docker)
- クリーンアーキテクチャ風味でGo!なTodoアプリ開発
- 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
による個別トランザクションによって、開発時データやテストケース間でデータが混在しないようにしています。
また、testify
のsuite
パッケージを使用して、共通で行いたい処理をまとめたりしています。
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
:重複しない値
参考記事
モックを使用したテスト
gomock
を導入したので、モックを生成してテストを書いてみました。
インターフェースを定義したファイルへのパスをmockgen
コマンドの-source
オプションで渡すと、それを元にモックを生成してくれます。
また、destination=
を指定しないと標準出力に出るようなので、適宜指定しましょう。指定した場合、ファイルは勝手に作られます。
mockgen -source=app/domain/repository/todoRepository.go -destination=app/mock/repository/mockTodoRepository.go
例示したコマンド内のパスは、プロジェクトで適当なパスに置き換えてください。
他にもオプションはあるようなので、詳しくは下記リポジトリのREADMEもご参照ください。
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(¬hingTodos, 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
の基本的な使い方としては、
-
gomock.NewController(t)
でモックの呼び出しを管理するControllerを生成する -
m.EXPECT().~
で期待する関数の呼び出しと戻り値を設定する - 参照するオブジェクトにモックを渡す
- テストしたい関数を実行する
といった感じです。
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
を使用した際の特殊なケースに遭遇したようです。
ここで、テストコードを見てみましょう。
…省略…
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
を呼び出します。
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公式のテストコードでも同じように対処されているので、それに倣いましょう。
おわりに
ここまでお読みいただき、ありがとうございました。
リダイレクトのステータスコードが返ってこない問題は沼にハマる一歩手前だったのですが、自己解決できて良かったです。
この記事を含め、本シリーズの記事が参考になれば幸いです。
参考資料
- Go 言語 testing チートシート
- Golangのtestify/assert 使えそうな関数まとめ
- gomockを完全に理解する
- GoでRubyみたいに単体テストを書きたくてやったこと
- DATA-DOG/go-txdbでDB接続を含むテストを楽に書こう
- GORMとgo-txdb(+Gin)で、DBを使ったテストを安全に実行する
-
GETリクエストでも、
Content-Type
がヘッダーにある場合は同様らしいですが ↩ -
本番で問題ないのか? と思われる方もいらっしゃるかもしれませんが、本番だと処理の際に
context.Writer.WriteHeaderNow
が呼ばれているようです : https://github.com/gin-gonic/gin/blob/6a0556ed5a67d1d12ae3e7ea2c0121b6c3b894e2/gin.go#L621 ↩