データベース依存のテストケースを作る際に、テストケース毎にDBがクリーンな状態を維持したい。
go-txdbはDBへの接続時にトランザクションを開始、切断時にトランザクションを終了するSQLドライバ。
テスト実行中にトランザクション内で発行したステートメント・行はテスト終了時には消滅する。
DB毎に実装方法は異なり、例えばSQLiteでは”トランザクション”ではなくsaveponitで実装される。
Package txdb is a single transaction based database sql driver. When the connection is opened, it starts a transaction and all operations performed on this sql.DB will be within that transaction. If concurrent actions are performed, the lock is acquired and connection is always released the statements and rows are not holding the connection.
【目次】
- ∨環境構築
- ∨サンプルデータの準備
- ∨CRUD ルーティング
- ∨手動リクエストと応答
- ∨txdbを使用するためのテスト用ヘルパー関数
- ∨テストの命名規則と共通処理
- ∨全件取得のテスト
- ∨1件取得のテスト(正常系)
- ∨1件取得のテスト(異常系)
- ∨1件追加のテスト(正常系)
- ∨1件追加のテスト(異常なパラメタ。異常系)
- ∨txdbが正しくトランザクションを分離していることのテスト
- ∨まとめ
環境構築
Claude Code (Sonnet4.5) で以下の環境を構成した。途中15回のエラーリカバリを挟んで
期待通りの環境が出来上がった。
main.goがアプリケーションのルーティング(ハンドラ共有)、
main_test.goが main.goのルートに対するテスト。テストにはTestMain()が含まれている。
test_repository_test.goはGinが生成したリポジトリ層(モデル)をルートを経由せずテストする。
$ tree . -L 2
.
├── data
│ └── db.sqlite # SQLite DBファイル
├── docker-compose.yml # Go+sqlite
├── Dockerfile # golang:1.23-alpineベース
├── gen.go # GORM Genコード生成スクリプト
├── go.mod # 依存関係の定義(go getやgo mod tidyで更新)
├── go.sum # 依存関係の検証用ハッシュ(自動)
├── init.sql # DDL,初期レコード
├── main.go # Gin初期化,ルーティング
├── main_test.go # main.goのルーティングに対するテストコード
├── models # モデル
│ ├── model # testsテーブルと対応する構造体定義 (自動生成)
│ └── query # (自動生成)
├── repository
│ └── test_repository_test.go # リポジトリ層(データアクセス層)のテスト
└── testhelper
└── db.go # TxDB初期化等テスト用ヘルパー
サンプルデータの準備
testsというテーブルに id, value というカラムを用意し、hoge, fuga レコードを挿入しておく。
簡略化のためにSQLiteを使用しており、ホスト側のファイルをbindマウントし初期実行判定して投入した。
-- Create tests table
CREATE TABLE IF NOT EXISTS tests (
id INTEGER PRIMARY KEY,
value TEXT NOT NULL
);
-- Insert initial data
INSERT OR IGNORE INTO tests (id, value) VALUES (1, 'hoge');
INSERT OR IGNORE INTO tests (id, value) VALUES (2, 'fuga');
CRUD ルーティング
gin, gorm(gen) を使用して testsテーブルに対するCRUDを行う以下のルートを定義した。
それぞれ、genを使用しGolang言語のレベルでオブジェクトを操作している。
| メソッド | エンドポイント| 説明 | 仕様 |
|--------|------------|----------------|-------------------------------------------------|
| GET | /hello | 全レコード取得 | Find()で全レコードを取得し返却 |
| GET | /hello/:id | 指定IDのレコード取得 | URLパラメータからIDを取得し、該当レコードを返却 |
| POST | /hello | 新規レコード追加 | JSONリクエストボディからidとvalueを受け取り新規作成 |
| PATCH | /hello/:id | 指定IDのレコード更新 | URLパラメータのIDとJSONボディのvalueでレコード更新 |
| DELETE | /hello/:id | 指定IDのレコード削除 | URLパラメータのIDでレコード削除. |
各ハンドラの詳細な実装は冗長なので割愛。
手動リクエストと応答
各エンドポイント に対するリクエストとレスポンスの関係は以下。期待通り。
# 全件取得し応答
$ curl http://localhost:8080/hello
[{"id":1,"value":"hoge"},{"id":2,"value":"fuga"}]
# id=1を取得し応答
$ curl http://localhost:8080/hello/1
{"id":1,"value":"hoge"}
# id=3を追加
$ curl -X POST http://localhost:8080/hello -H "Content-Type: application/json" -d '{"id":3,"value":"piyo"}'
{"id":3,"value":"piyo"}
$ curl http://localhost:8080/hello
[{"id":1,"value":"hoge"},{"id":2,"value":"fuga"},{"id":3,"value":"piyo"}]
# id=3を変更
$ curl -X PATCH http://localhost:8080/hello/3 -H "Content-Type: application/json" -d '{"value":"updated_piyo"}'
{"id":3,"value":"updated_piyo"}
# id=3を削除
$ curl -X DELETE http://localhost:8080/hello/3
{"message":"record deleted successfully"}
# 全件取得し応答
$ curl http://localhost:8080/hello
[{"id":1,"value":"hoge"},{"id":2,"value":"fuga"}]
txdbを使用するためのテスト用ヘルパー関数
txdbを使用するためのテスト用ヘルパー関数を以下のように定義しておく。
package testhelper
import (
"database/sql"
"fmt"
"os"
"sync"
"sync/atomic"
"github.com/DATA-DOG/go-txdb"
_ "github.com/mattn/go-sqlite3"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var (
once sync.Once
connID atomic.Uint64
)
// SetupTxDB initializes txdb driver for testing
func SetupTxDB() {
once.Do(func() {
// Get database path
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "./data/db.sqlite"
}
// Register txdb driver with SQLite-specific options
// Use WAL mode and configure for better concurrent access
dsn := fmt.Sprintf("%s?_journal_mode=WAL&_busy_timeout=5000", dbPath)
txdb.Register("txdb", "sqlite3", dsn)
})
}
// NewTestDB creates a new test database connection with txdb
// Each connection will be isolated in a transaction and rolled back after test
func NewTestDB() (*gorm.DB, error) {
SetupTxDB()
// Open connection using txdb driver with unique connection ID
// This ensures each test gets its own isolated transaction
id := connID.Add(1)
sqlDB, err := sql.Open("txdb", fmt.Sprintf("connection_%d", id))
if err != nil {
return nil, fmt.Errorf("failed to open txdb connection: %w", err)
}
// Wrap with GORM
db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB,
}, &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to open gorm connection: %w", err)
}
return db, nil
}
テストの命名規則と共通処理
テストの関数名はTestXXX()のようにTestから始まりキャメルケースを続ける。
TestMain()内に全ての処理の前に実行する処理、後に実行する処理を記述できる。
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"gin_txdb/testhelper"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) {
// Set DB_PATH for testing
os.Setenv("DB_PATH", "./data/db.sqlite")
// Set Gin to test mode
gin.SetMode(gin.TestMode)
// Run tests
code := m.Run()
os.Exit(code)
}
全件取得のテスト
ヘルパー関数のNewTestDB()を使用することでtxdbを使用してDBに接続している。
defer func()内でコネクションを明示的にクローズする処理を遅延評価(=テスト完了時評価)しているが、
テスト実行中にエラーやpanicが起きた場合に開いたDBを切ることができなくなる問題への対処。
特にSQLiteの場合「接続は常に1つ」なので、切り忘れで接続が開きっぱなしになると、
次のテスト実行でロックエラーが発生する。明示的に閉じることでこの問題を確実に回避できる。
後はアサートを書いていく。
func TestGetAllTests(t *testing.T) {
// Setup test database with txdb
db, err := testhelper.NewTestDB()
require.NoError(t, err)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Setup router using main.go's SetupRouter
router := SetupRouter(db)
// Create request
req, _ := http.NewRequest(http.MethodGet, "/hello", nil)
w := httptest.NewRecorder()
// Perform request
router.ServeHTTP(w, req)
// Assert response
assert.Equal(t, http.StatusOK, w.Code)
var response []map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Should have 2 initial records
assert.Len(t, response, 2)
assert.Equal(t, float64(1), response[0]["id"])
assert.Equal(t, "hoge", response[0]["value"])
assert.Equal(t, float64(2), response[1]["id"])
assert.Equal(t, "fuga", response[1]["value"])
}
このテストだけ実行してみる。-run オプションでテスト名を指定する。
$ go test -run TestGetAllTests
[GIN] 2025/10/15 - 17:17:44 | 200 | 238.666µs | | GET "/hello"
PASS
ok gin_txdb 0.496s
1件取得のテスト(正常系)
[GET] /hello/:id のテスト。指定したIDが存在する正常系。
func TestGetTestByID_Success(t *testing.T) {
// Setup test database with txdb
db, err := testhelper.NewTestDB()
require.NoError(t, err)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Setup router
router := SetupRouter(db)
// Create request
req, _ := http.NewRequest(http.MethodGet, "/hello/1", nil)
w := httptest.NewRecorder()
// Perform request
router.ServeHTTP(w, req)
// Assert response
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, float64(1), response["id"])
assert.Equal(t, "hoge", response["value"])
}
実行結果は以下の通り。
go test -run TestGetTestByID_Success
[GIN] 2025/10/15 - 17:24:41 | 200 | 207.25µs | | GET "/hello/1"
PASS
ok gin_txdb 0.330s
1件取得のテスト(異常系)
[GET] /hello/:idのテスト。指定したIDが見つからない異常系。
func TestGetTestByID_NotFound(t *testing.T) {
// Setup test database with txdb
db, err := testhelper.NewTestDB()
require.NoError(t, err)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Setup router
router := SetupRouter(db)
// Create request for non-existent ID
req, _ := http.NewRequest(http.MethodGet, "/hello/999", nil)
w := httptest.NewRecorder()
// Perform request
router.ServeHTTP(w, req)
// Assert response
assert.Equal(t, http.StatusNotFound, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "record not found", response["error"])
}
実行結果は以下の通り。
go test -run TestGetTestByID_NotFound
./gin_txdb/main.go:52 record not found
[0.105ms] [rows:0] SELECT * FROM `tests` WHERE `tests`.`id` = 999 ORDER BY `tests`.`id` LIMIT 1
[GIN] 2025/10/15 - 17:22:45 | 404 | 542.875µs | | GET "/hello/999"
PASS
ok gin_txdb 0.672s
1件追加のテスト(正常系)
[POST] /helloが正常終了した場合、追加したレコードをレスポンスで返す処理のため、
レスポンスで返ってきたデータをアサートしている。
その後、[GET] /hello/:id のレスポンスを使ってアサートしている。
func TestCreateTest_Success(t *testing.T) {
// Setup test database with txdb
db, err := testhelper.NewTestDB()
require.NoError(t, err)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Setup router
router := SetupRouter(db)
// Create request body
payload := map[string]interface{}{
"id": 100,
"value": "test_value",
}
body, _ := json.Marshal(payload)
// Create request
req, _ := http.NewRequest(http.MethodPost, "/hello", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Perform request
router.ServeHTTP(w, req)
// Assert response
assert.Equal(t, http.StatusCreated, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, float64(100), response["id"])
assert.Equal(t, "test_value", response["value"])
// Verify the record was actually created
req2, _ := http.NewRequest(http.MethodGet, "/hello/100", nil)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
}
実行結果は以下の通り。
$ go test -run TestCreateTest_Success
[GIN] 2025/10/15 - 17:30:04 | 201 | 398.167µs | | POST "/hello"
[GIN] 2025/10/15 - 17:30:04 | 200 | 47.625µs | | GET "/hello/100"
PASS
ok gin_txdb 0.505s
1件追加のテスト(異常なパラメタ。異常系)
testsレコードはid,valueカラムを持つ。idのみ(valueなし)を渡した場合400エラーを返す。
func TestCreateTest_MissingFields(t *testing.T) {
// Setup test database with txdb
db, err := testhelper.NewTestDB()
require.NoError(t, err)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Setup router
router := SetupRouter(db)
// Create request body with missing value field
payload := map[string]interface{}{
"id": 100,
}
body, _ := json.Marshal(payload)
// Create request
req, _ := http.NewRequest(http.MethodPost, "/hello", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Perform request
router.ServeHTTP(w, req)
// Assert response
assert.Equal(t, http.StatusBadRequest, w.Code)
}
実行結果は以下の通り。
期待通り400エラーが返ったことをアサートできた。
go test -run TestCreateTest_MissingFields
[GIN] 2025/10/15 - 17:36:49 | 400 | 139.709µs | | POST "/hello"
PASS
ok gin_txdb 0.501s
txdbが正しくトランザクションを分離していることのテスト
Claude Code (Sonnet4.5) が (指示していないのに) 自動的にこのテストを作成してくれた。
お勉強を兼ねたテストプロジェクトであることを伝えたために、気を利かせてくれた感がある。
以下をテストする。
- トランザクション内での一貫性 (トランザクション内で作成したデータを同一トランザクション内で観察できる)
- トランザクション間の分離 (別のトランザクションで作成したデータを観察できない。テストは独立している)
- 自動ロールバックの動作確認 (txdbがClose()時に自動的にロールバックを実行している。DBは初期状態に戻る)
あくまで、一貫性、分離、ロールバックの一例を見せてもらうだけなのだが、
こういうことをやりたいのだな、という背景を理解できたのでお勉強としては十分。
func TestTransactionIsolation(t *testing.T) {
// This test demonstrates that each test gets isolated transactions
t.Run("Test1_CreateRecord", func(t *testing.T) {
db, err := testhelper.NewTestDB()
require.NoError(t, err)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
router := SetupRouter(db)
// Create a new record
payload := map[string]interface{}{
"id": 200,
"value": "test200",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost, "/hello", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
// Verify it exists in this transaction
req2, _ := http.NewRequest(http.MethodGet, "/hello/200", nil)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
})
t.Run("Test2_VerifyRollback", func(t *testing.T) {
db, err := testhelper.NewTestDB()
require.NoError(t, err)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
router := SetupRouter(db)
// The record created in Test1 should not exist (rolled back)
req, _ := http.NewRequest(http.MethodGet, "/hello/200", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Should still have only 2 original records
req2, _ := http.NewRequest(http.MethodGet, "/hello", nil)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
var response []map[string]interface{}
json.Unmarshal(w2.Body.Bytes(), &response)
assert.Len(t, response, 2)
})
}
まとめ
go-txdbを使うことで、テストケース毎にDBを分離できることを確認した。
あればかなり便利だと思う。