GoのDBフィクスチャを脱YAML・型安全化する

ogp

この記事はenechain Advent Calendar 2025の10日目の記事です。

本日はenechainのGXデスク バックエンドエンジニアの @eji が担当します。

1. はじめに

GXデスクで開発している環境価値取引所JCEXのバックエンドサーバーはGoで書いており、DBと結合させた状態でAPIテストを書いています。

テストのフィクスチャはBunのFixtureを利用しており、APIテストのテストケースごとにフィクスチャをDBにロードしてテストしています。

今回はフィクスチャ管理で私たちが感じた課題と、その課題に対してどのようなアプローチで解決に取り組んだか、そしてその結果どうなったかについて紹介したいと思います。

2. 対象読者

  • GoでDBを使ったテストを書いている方
  • テストフィクスチャのメンテナンスを改善したいと考えている方
  • YAMLでのフィクスチャ管理に限界を感じている方

2.1. 前提条件

  • Go言語の基本的な知識
  • SQLデータベースを使用したアプリケーション開発の経験

3. 課題

JCEXではBunのフィクスチャ機能を利用していましたが、開発が進むにつれて以下のような課題を感じていました。

3.1. IDEの補完が効かない

YAMLでフィクスチャを定義しているため、IDEの強力な入力補完機能が使えません。 最近はAIで生成することも増えてきましたが、手動で修正する場合も多く、フィールド名のタイプミスなどに気づきにくい状態でした。

3.2. DBスキーマ変更の検知が難しい

開発中にカラム名を変更したり削除したりした場合、YAMLファイルは文字列として扱われるため、コンパイルエラーになりません。 テストを実行して初めて sql: unknown column といったエラーで失敗することになり、修正サイクルが遅くなってしまいます。 特にマイグレーションファイルを作成した段階で、影響を受けるテストフィクスチャを検知したいという思いがありました。

4. 解決案

これらの課題を解決するために、私たちは次のような要件を満たす仕組みを探しました。

  1. テストフィクスチャを書くときにGoの構造体として定義でき、補完が効くこと
  2. カラムの変更や削除がテスト実行前(ビルド時や生成時)に検知できること

既存のフィクスチャ管理ツールもいくつか調査しましたが、私たちの要求(特にBunやAtlasを使用している既存のフローとの親和性)を完全に満たすものは見つかりませんでした。

そこで、自前でパッケージを作成することにしました(実装はAIを使って書きました)。パッケージ名は syd と名付けました。

4.1. 検討した他の案:Bunのモデルをそのまま使う

最初は、ORMであるBunのモデル定義をそのままテストフィクスチャとして利用することも検討しました。 しかし、以下の理由からこの案は採用しませんでした。

  • ORMへの依存リスク: 将来的にORMをBunから別のライブラリへ移行することになった場合、テストフィクスチャの定義もすべて修正する必要が出てくる。
  • 責務の分離: アプリケーションコード(ORMモデル)とテストデータ定義(フィクスチャ)は分離しておく方が、それぞれの変更の影響を最小限に抑えられる。

そのため、ORMとは独立した「テスト専用の構造体」をDBスキーマから生成する方針としました。

4.2. sydの設計思想

syd を作るにあたり、開発メンバーが私を含めて2名という少人数のチームでも無理なくメンテナンスできるよう、コストを最小限に抑えることを最優先に考えました。

そのために、以下の設計指針を設けました。

  • 機能を絞る: あらゆるユースケースに対応する汎用的なツールを目指すのではなく、自分たちが直面している課題(テストフィクスチャの管理)を解決するために必要最小限の機能だけに留めることとした。
  • 標準ライブラリ中心: サードパーティ製ライブラリへの依存は極力避け、Goの標準ライブラリを中心に構成した。これにより、将来的な依存ライブラリの更新や廃止リスクを低減している。
  • Goエンジニアに馴染みやすい: Goの標準的な慣習(structタグや database/sql インターフェース)に従い、他のGo開発者がコードを読んですぐに理解できる作りを意識した。

4.3. 機能の取捨選択

設計思想に基づき、実装する機能とあえて実装しない機能を明確に分けました。

実装した機能

  • Goのstruct生成 (マルチスキーマ対応): JCEXではPostgreSQLのスキーマ機能を利用してテーブルを管理しているため、複数のスキーマに対応する必要があった。
  • DBへのバルクインサート: 現在、APIテストのケース数は802個あり、全体の実行時間は約27秒である。 テスト実行には go-txdb を利用してケースごとにトランザクションをロールバックしているが、毎回フィクスチャをロードするため、少しでもオーバーヘッドを減らすためにバルクインサートを採用した。

あえて実装しなかった機能

  • 外部キー制約の解決 (依存順序の自動ソート): テストで投入するフィクスチャは限られたテーブルであることが多く、開発者が依存関係を把握しているため、インサート順序で困ることは少ないと判断した。
  • COPYコマンドによるロード: 通常のAPIテストで数万件単位の大量データを投入することは稀であるため、実装コストのかかるCOPY機能は見送った。

4.4. どうやって作ったか

「欲しいツールがないなら作る」とはいえ、スクラッチでの実装は工数がかかります。そこで今回はAIエージェントをフル活用しました。

  1. インターフェース設計: まず、「入力(DBスキーマ)」と「出力(Goのstruct)」、そして「テストコードでの理想的な書き方」をプロンプトとして与え、AIと相談しながらテストコードを先に作成しました。
  2. 実装: テストが固まった後、パッケージレイアウトを決定し、AIエージェントにそのテストを通すための実装コードを書かせました。

結果として、実質1日程度で動くパッケージを完成させることができました。

4.4. sydの内部構造

syd の内部は非常にシンプルです。

タグ付けの仕組み

CLIツールは、DBスキーマ情報を読み取り、Goの text/template を使って構造体定義を生成します。 この際、マルチスキーマに対応するため、構造体名は SchemaTable(例: gx.ordersGxOrders)の形式で生成し、テーブル名(スキーマ名含む)やカラム名を syd という独自のstructタグとして埋め込みます。

// gxスキーマのordersテーブルの場合
type GxOrders struct {
    // テーブル名(スキーマ含む)は BaseModel のタグで指定
    syd.BaseModel `syd:"gx.orders"`

    // カラム名はフィールドのタグで指定
    ID     int64  `syd:"id"`
    Status string `syd:"status"`
}

バルクインサートの仕組み

テスト実行時、syd.Import 関数は渡された構造体を reflect パッケージを使って解析します。

  1. syd タグからテーブル名とカラム名を読み取る。
  2. 連続する同じテーブルのデータをグルーピングする。
  3. SQLの INSERT 文(バルクインサート)を生成し、database/sql を通じて実行する。

特に外部キー制約の解決(依存順序の自動ソート)をあえて実装していません。 依存関係の解決まで行うと、ツールが単なるデータローダーの域を超え、複雑なORMのような責務を持つことになってしまうためです。 sydの目的はあくまで「テストフィクスチャを型安全に定義すること」に集中させ、複雑な依存解決はスコープ外とする設計にしました。

これにより、ツール自体のシンプルさを保ち、メンテナンスコストを最小限に抑えています。

5. 実践

実際にどのような手順で導入したかを紹介します。

5.1. Go structの自動生成

JCEXでは Atlas を使ってDBスキーマを管理しています。 Atlasでマイグレーションファイルを生成するフローの中にこのCLIツールを組み込むことで、DBスキーマが変更されると即座にGoのstructも更新されるようにしました。

以下は、DBスキーマからGoのstructを生成するために用意したスクリプトです。 Atlasを使って一時的なDBに最新のスキーマを適用し、そこから syd CLIを使ってGoのコードを生成しています。

#!/bin/sh

# BunのモデルからAtlasのスキーマファイルを生成
atlas migrate diff --env bun

# 一時的なDBにマイグレーションを適用
atlas migrate apply --dir file://$DB_MIGRATIONS_DIR --url $DB_URL

# syd CLIを実行してDBからGoのstructを生成
go run ./pkg/syd/cli/main.go -db $DB_URL -output $SYD_OUTPUT_DIR -verbose

これにより、カラム名が変わればGoのstructのフィールド名も変わり、それを参照しているテストコードがコンパイルエラーになるため、変更をすぐに検知できるようになります。

補足

go generate -tags xxx でテスト用structを生成させることもできますが、一時DBの事前準備が必要なのとマイグレーションファイル生成時にのみコード生成したいため、あえて明示的にコマンドを実行してコードを生成しています。

5.2. 生成されたコード

CLIによって生成されるコードは以下のようになります。 syd タグが付与されており、これを元にバルクインサートが行われます。

// Code generated by syd. DO NOT EDIT.
// Generated from database schema by pkg/syd. Any manual edits will be lost.

package sydmodels

import (
    "encoding/json"
    "github.com/my-org/my-app/pkg/syd"
    "time"
)

// GxOrders represents the gx.orders table
type GxOrders struct {
    syd.BaseModel `syd:"gx.orders"`

    ID        int64           `syd:"id"`
    Status    string          `syd:"status"`
    Details   json.RawMessage `syd:"details"`
    UserID    string          `syd:"user_id"`
    ShopID    *string         `syd:"shop_id"`
    CreatedAt time.Time       `syd:"created_at"`
}

// GxUsers represents the gx.users table
type GxUsers struct {
    syd.BaseModel `syd:"gx.users"`

    ID        string    `syd:"id"`
    Name      string    `syd:"name"`
    Email     string    `syd:"email"`
    CreatedAt time.Time `syd:"created_at"`
}

5.3. テストでの利用

生成されたstructを使ってテストを書くと以下のようになります。 YAMLではなくGoのコードとしてフィクスチャを定義できるため、IDEの補完が効き、型チェックも行われます。

package sample_test

import (
    ...
    "github.com/my-org/my-app/pkg/syd"
    "github.com/my-org/my-app/pkg/syd/dialect/pgdialect"
)

func TestListOrders(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    // *sql.DB
    sqlDB := ...
 
    fixtures := []syd.Model{
        // Goのstructで書けるので補完が効く
        sydmodels.GxUsers{
            ID:    "user_1",
            Name:  "Alice",
            Email: "alice@example.com",
        },
        sydmodels.GxOrders{ID: 1, Status: "completed", UserID: "user_1"},
        sydmodels.GxOrders{ID: 2, Status: "pending", UserID: "user_1"},
        sydmodels.GxOrders{ID: 3, Status: "cancelled", UserID: "user_1"},
    }
 
    // 定義したフィクスチャをDBにロード
    err := syd.Import(ctx, fixtures, sqlDB, pgdialect.NewPostgresDialect())
    require.NoError(t, err)
 
    // ... (以下、テストの実行と検証)
}

6. その後の効果

この仕組みを導入してから約3ヶ月が経ちました。 現在、新しいAPIテストのフィクスチャは全てこのパッケージを使って記述しています。

導入の結果、以下のような効果が得られました。

  • 高い記述効率と型安全性: フィクスチャがGoのコードであるため、IDEの補完機能によりカラム名を正確に記述できる。また、型定義により不正な値をコンパイル時に排除できる。
  • 変更検知のシフトレフト: スキーマ変更に伴うフィクスチャの修正漏れを、CIでのテスト実行時(Runtime)ではなく、ビルド時(Compile time)で検知できるようになった。
  • メンテナンス性の向上: 構造体定義へジャンプできるため、どのテーブルにどのようなカラムがあるかがコード上で確認しやすくなった。

当初の目的であった「テストフィクスチャを書く時に補完がサポートされる」「カラムの変更や削除がテスト実行前に検知できる」という課題は完全に解決されました。

7. まとめ

本記事では、GoのDBテストにおけるフィクスチャ管理を、YAMLからGoの構造体へ移行することで改善した事例を紹介しました。

この改善により、以下の状態を実現できました。

  1. フィクスチャ定義の型安全化: データ作成時にIDEの補完が効き、ミスを未然に防げる。
  2. スキーマ変更への追従: カラムの変更がコンパイルエラーとして検知され、修正箇所が即座に特定できる。

また、解決策の検討においては既存ライブラリの調査も行いましたが、要件を完全に満たすものが見つからなかったため、「AIを活用して自作する」という選択に至りました。

「外部キー解決などの複雑な機能は持たせず、フィクスチャ定義のみに集中する」という割り切りを行うことで、自分たちのユースケースに最適化されたツールを短期間で手に入れることができました。

導入から3ヶ月が経過しましたが、機能を絞り込んだおかげか syd 自体の修正はほとんど発生しておらず、安定的に利用できています。 「少人数チームでも運用できるツールにする」という当初の狙いは、今のところ成功していると言えそうです。

「欲しいツールがなければAIと作る」というアプローチは、今後の開発環境改善において強力な選択肢になると実感しています。 本記事が、同様の課題を持つGoエンジニアの方々の参考になれば幸いです。


enechainでは、事業拡大のために随時仲間を募集しています。興味がある方はぜひお声がけください!

tech.enechain.com

herp.careers