ENT(Go)でトランザクションを楽にテストする方法について
前提:Go 1.24系 / ent v0.14.x / testify + mockery
対象読者
- Ent を触り始め、Txまわりのユニットテストでハマっている
-
testify/mock+mockeryでモック生成している - panic と “expectation unmet”(期待未充足)のエラーが出る
ゴール
- panic の正体(内部で何が起きているか)を理解する
- ユニットテストと統合テストの境界を見極める
- 実物を一部使う方針で、スッキリ動くテストの書き方を示す
1. 事の発端──panic とモック未充足
よく見るエラー
panic: interface conversion: interface {} is *tx.MockTxManager, not *ent.Tx
...snip...
interface conversion: dialect.Driver is nil, not *ent.txDriver
もしくは
assert: mock: I don't know what to return...
(= Commit/Rollback の呼び出し期待が満たされていない)
「
TxなんだからCommit()を呼べば満たされるはず…」
→ そのTxが“本物”じゃない(内部 driver がない)と、Commit()の途中で落ちます。
2. Ent の *ent.Tx の中で何が起きているか
client.Tx(ctx) が返すのは次のような構造体(簡略化)です。
type Tx struct {
config
driver dialect.Driver // ← 実ドライバが入る(*ent.txDriver を期待)
}
-
コンストラクタは非公開。手で
&ent.Tx{}を new してもdriver == nil。 -
Commit()/Rollback()の内部でd := tx.driver.(*txDriver) // 型アサーションが走るので、
driver == nilや異なる型だと ここで panic。
つまり:
MockTxManagerのような別型を返す → 型変換で即 panic&ent.Tx{}のように driver が nil な “空ハコ”を返す → Commit 中に panic
3. 「全部モックでやる」が難しい理由
正常系までユニットテストだけで完結させるなら、
-
*ent.Txを 埋め込みしたラッパーを作り - 内部
driverに 自作モックドライバを差し込み -
Commit/Rollbackの呼び回数をそこで計測
…という、コスパの悪い作業が必要になります。
ここは発想を転換し、「悪いルートをユニットで、良いルートは統合で」が楽です。
4. どこまでをユニットで担保するか
| 観点 | ユニットで担保 | 手段 |
|---|---|---|
| Tx 開始に失敗したらそのエラーを返す | 〇 |
client.Tx(ctx) をエラーで返す |
fn が error を返したら Rollback し、その error を返す |
〇 |
本物の *ent.Tx を返し、fn に任意エラーを返させる |
fn が panic したら Rollback して panic 再送出
|
〇 | 同上(assert.Panics で検証) |
| 正常系で Commit される | △(統合へ) | 統合/結合テストで DB に反映された事実を後検証 |
5. 実装:Tx 境界のヘルパと“実Txを一部だけ使う”テスト
5.1 Tx 境界のヘルパ(ユニット対象)
// txutil/withtx.go
package txutil
import (
"context"
"your/module/ent"
)
// TxBeginner: ent.Client と同じ形だけを要求(最小インターフェース)
type TxBeginner interface {
Tx(ctx context.Context) (*ent.Tx, error)
}
// WithTx: 例外(panic)も含め、Tx境界の標準パターン
func WithTx(ctx context.Context, b TxBeginner, fn func(context.Context, *ent.Tx) error) (err error) {
tx, err := b.Tx(ctx)
if err != nil {
return err
}
// panic も Rollback してから再送出
defer func() {
if r := recover(); r != nil {
_ = tx.Rollback()
panic(r)
}
}()
if err = fn(ctx, tx); err != nil {
_ = tx.Rollback()
return err
}
if err = tx.Commit(); err != nil {
_ = tx.Rollback()
return err
}
return nil
}
5.2 モックの用意(mockery)
# 例:ent.Client 互換の TxBeginner をモック生成
mockery --name TxBeginner --dir txutil --output txutil/mocks --case underscore
既存の
ent.Clientを直接モックしてもOKです(Tx(ctx)だけ使う想定)。
5.3 “実Tx” を返す準備(テスト側)
// ユニットテストで使う in-memory SQLite の実client
import (
"entgo.io/ent/enttest"
_ "github.com/mattn/go-sqlite3" // driver 登録(blank import 必須)
)
func newRealClient(t *testing.T) *ent.Client {
t.Helper()
cli := enttest.Open(t, "sqlite3", "file:memdb?mode=memory&_fk=1")
t.Cleanup(func() { _ = cli.Close() })
return cli
}
5.4 失敗ルートのユニットテスト(3ケース)
package txutil_test
import (
"context"
"testing"
"your/module/ent"
"your/module/txutil"
"your/module/txutil/mocks" // mockery 生成物
"entgo.io/ent/enttest"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// 実Txを取るヘルパ
func realTx(t *testing.T) *ent.Tx {
cli := enttest.Open(t, "sqlite3", "file:memdb?mode=memory&_fk=1")
t.Cleanup(func() { _ = cli.Close() })
tx, err := cli.Tx(context.Background())
require.NoError(t, err)
return tx
}
func Test_WithTx_BeginError(t *testing.T) {
ctx := context.Background()
m := mocks.NewTxBeginner(t)
m.On("Tx", mock.Anything).Return((*ent.Tx)(nil), assert.AnError)
err := txutil.WithTx(ctx, m, func(context.Context, *ent.Tx) error {
t.Fatal("fn should not be called")
return nil
})
require.Error(t, err)
assert.Equal(t, assert.AnError, err)
m.AssertExpectations(t)
}
func Test_WithTx_FnReturnsError_Rollback(t *testing.T) {
ctx := context.Background()
m := mocks.NewTxBeginner(t)
m.On("Tx", mock.Anything).Return(realTx(t), nil)
boom := assert.AnError
err := txutil.WithTx(ctx, m, func(context.Context, *ent.Tx) error {
// ここで好きに ent を使ってよい(実Txなので panic しない)
return boom
})
require.Error(t, err)
assert.Equal(t, boom, err)
m.AssertExpectations(t)
}
func Test_WithTx_FnPanics_RollbackAndRepanic(t *testing.T) {
ctx := context.Background()
m := mocks.NewTxBeginner(t)
m.On("Tx", mock.Anything).Return(realTx(t), nil)
assert.Panics(t, func() {
_ = txutil.WithTx(ctx, m, func(context.Context, *ent.Tx) error {
panic("boom") // panic 経路
})
})
m.AssertExpectations(t)
}
ポイント
- モックは 「Tx が呼ばれた」 だけを検証。
Commit/Rollbackの回数は数えない(観点外)。 - 返すのは“実物の
*ent.Tx” なので、Commit/Rollback中に driver 変換で落ちない。
6. 正常コミットは「統合テスト」が楽で堅い
「本当にコミットされた」を保証するには、実DB に対する後検証が最もシンプルです。
- 例:
WithTx内で INSERT → 関数戻り後に 別コネクション/別プロセス で SELECT し、永続化を確認。
※ in-memory SQLite(file:memdb?...)はコネクション共有に注意。確実に検証したい場合はファイルSQLiteやDocker上のPostgresに切り替えると吉。 - 代替として
sqlmockでBEGIN/INSERT/COMMITを期待にしてもよいが、Ent の SQL 生成が変わると壊れやすい。
7. よくある落とし穴(失敗例 → 解)
❌ 失敗1:空の &ent.Tx{} を返す
m.On("Tx", mock.Anything).Return(&ent.Tx{}, nil) // driver==nil
// → Commit 中に panic
✅ 解:enttest.Open から 実Tx を取って返す。
❌ 失敗2:独自 MockTxManager を返す
type MockTxManager struct{}
m.On("Tx", mock.Anything).Return(&MockTxManager{}, nil)
// → *ent.Tx への型変換で panic
✅ 解:戻り値は必ず *ent.Tx(=本物)にする。
❌ 失敗3:Commit() 呼び回数をモックで数える
- そもそも
Commitは*ent.Txの内部実装。 - **“何回呼ばれたか”**は Tx境界のユースケース上ほぼ価値がない(呼ぶべき所で呼べたかが重要)。
✅ 解:ユニットでは 失敗ルートのふるまい(Rollback+再送出/エラー透過)に集中。
正常コミットは 統合テストに回す。
8. 仕上げのチェックリスト
-
TxBeginnerは 最小インターフェース(Tx(ctx)のみ)に切れている -
WithTxは panic も Rollback して再送出する - ユニットでは Tx開始エラー / fnエラー / fn panic の3系統を網羅
- テストで返すのは enttest.Open 由来の ent.Tx
- 正常コミットは統合テスト(実DBで後検証 or sqlmock 監視)
-
mock.AssertExpectations(t)で入口だけ検証(回数数え地獄に行かない)
9. まとめ
| やりたいこと | 層 | 推奨手段 |
|---|---|---|
Tx開始エラー、fn の error/panic ハンドリング |
Unit |
enttest.Open の 実Tx をモック経由で返す |
| 正常系コミットの保証 | IT/E2E |
実DBの後検証(別コネクションで SELECT)or sqlmock
|
Commit/Rollback の回数を数えたい |
— | やらない(コスパ悪・価値小) |