go-txdbを使ってgolang, gin, gorm(gen)+sqlite構成のAPI をテストケース毎に管理する

データベース依存のテストケースを作る際に、テストケース毎にDBがクリーンな状態を維持したい。
go-txdbはDBへの接続時にトランザクションを開始、切断時にトランザクションを終了するSQLドライバ。
テスト実行中にトランザクション内で発行したステートメント・行はテスト終了時には消滅する。
DB毎に実装方法は異なり、例えばSQLiteでは”トランザクション”ではなくsaveponitで実装される。

環境構築

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を分離できることを確認した。
あればかなり便利だと思う。