Goでテストのフィクスチャをいい感じに書く

Merpay Tech Openness Month 2022の6日目の記事です。

こんにちは、Merpay Credit Design Teamでバックエンドエンジニアをしている@youxkeiです。

テストを書く際、その前提条件としてデータベースの状態をフィクスチャとして準備して、データベースにデータを投入することがよくあります。このフィクスチャはYAMLなどの外部ファイルに書かれることもありますが、この記事ではテストコード上にGoで記述する方法を考えていきます。

この記事では、データベースはリレーショナルデータベースを想定していて、具体例として架空の図書館蔵書管理システムのデータベースを使っています。

素直にモデルを使う

多くの場合、以下のようにデータベースのそれぞれのテーブルに対してモデルが定義されています。

package model

import (
    "time"
)

type Library struct {
    LibraryID string
    Name string
    CreatedAt time.Time
    UpdatedAt time.Time
}

type Book struct {
    BookID string
    Name string
    LibraryID string // 本がある図書館
    CreatedAt time.Time
    UpdatedAt time.Time
}

type Author struct {
    AuthorID string
    Name string
    CreatedAt time.Time
    UpdatedAt time.Time
}

type BookAuthorMapping struct {
    BookID string
    AuthorID string
    CreatedAt time.Time
    UpdatedAt time.Time
}

このモデルを直接テストコードで生成してデータベースのデータを作る方法が考えられます。

import (
    "testing"
    "time"

    "github.com/google/uuid"
    // path to model package
    // path to modeltest package
)

func TestListBooksByName(t *testing.T) {
    library := &model.Library{
        LibraryID: uuid.New().String(),
        Name:      "ほげ図書館",
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }
    book := &model.Book{
        BookID:    uuid.New().String(),
        Name:      "吾輩は猫である",
        LibraryID: library.LibraryID,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }
    modeltest.Setup(t, library, book) // テスト用にDBをセットアップする
    // 以下にテストコードが続く
}

この方法では、用意する各モデルのデータのすべてのフィールドを明示的に設定しています。しかし、一意なIDを設定したり、CreatedAtやUpdatedAtといった現在時間を設定するのは、決まり切ったパターンのコードになってしまいます。この例で本質的なのは、図書館や本の名前を表すNameであるのと、本がどの図書館に属しているかの関連を設定しているLibraryIDです。

デフォルト値を用意する

モデルのデータに対して決まり切った値の設定としてデフォルト値を設定すると同時に渡されたセッターを通して、デフォルト値を上書きできるような、次のような関数を用意することで、本質的なフィールドだけをテストコードから設定すればよくなります。

package modeltest

import (
    "time"

    "github.com/google/uuid"
    // model package
    // modeltest package
)

func Library(setter func(library *model.Library)) *model.Library {
    library := &model.Library{
        LibraryID: uuid.New().String(),
        Name:      "placeholder library",
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }

    setter(library)

    return library
}

func Book(setter func(book *model.Book)) *model.Book {
    book := &model.Book{
        BookID:    uuid.New().String(),
        Name:      "placeholder book",
        LibraryID: uuid.New().String(),
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }

    setter(book)

    return book
}

func Author(setter func(author *model.Author)) *model.Author {
    author := &model.Author{
        AuthorID:  uuid.New().String(),
        Name:      "placeholder author",
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }

    setter(author)

    return author
}

func BookAuthorMapping(setter func(bookAuthorMapping *model.BookAuthorMapping)) *model.BookAuthorMapping {
    bookAuthorMapping := &model.BookAuthorMapping{
        BookID:    uuid.New().String(),
        AuthorID:  uuid.New().String(),
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }

    setter(bookAuthorMapping)

    return bookAuthorMapping
}

これで、先ほどのTestListBooksByNameを書き直すと、次のようになります。

func TestListBooksByName(t *testing.T) {
    library := modeltest.Library(func(l *model.Library) {
        l.Name = "ほげ図書館"
    })
    book := modeltest.Book(func(b *model.Book) {
        b.Name = "吾輩は猫である"
        b.LibraryID = library.LibraryID
    })
    modeltest.Setup(t, library, book)
    // 以下にテストコードが続く
}

かなり良くなりました。しかし、次のように多くのデータを準備する例を考えてみてください。

func TestListBooksByAuthor(t *testing.T) {
    library1 := modeltest.Library(func(l *model.Library) {
        l.Name = "ほげ図書館"
    })
    library2 := modeltest.Library(func(l *model.Library) {
        l.Name = "ぴよ図書館"
    })
    book1 := modeltest.Book(func(b *model.Book) {
        b.Name = "吾輩は猫である"
        b.LibraryID = library1.LibraryID
    })
    book2 := modeltest.Book(func(b *model.Book) {
        b.Name = "河童"
        b.LibraryID = library2.LibraryID
    })
    author1 := modeltest.Author(func(a *model.Author) {
        a.Name = "夏目漱石"
    })
    author2 := modeltest.Author(func(a *model.Author) {
        a.Name = "芥川龍之介"
    })
    bam1 := modeltest.BookAuthorMapping(func(bam *model.BookAuthorMapping) {
        bam.BookID = book1.BookID
        bam.AuthorID = author1.AuthorID
    })
    bam2 := modeltest.BookAuthorMapping(func(bam *model.BookAuthorMapping) {
        bam.BookID = book2.BookID
        bam.AuthorID = author2.AuthorID
    })
    modeltest.Setup(t, library1, library2, book1, book2, author1, author2, bam1, bam2)
    // 以下テストコードが続く
}

この方法では、各モデル間の関連を設定するために、LibraryID、BookID、AuthorIDを設定しなければなりません。そのために、作成したデータを区別するために、変数名に1とか2を付けています。この方法では、フィクスチャとして準備するモデルのデータ数がもっと多くなると、コードが煩雑になってしまいます。

データの関係性をうまく表現する

先ほどの例での、LibraryID、BookID、AuthorIDを明示的に設定するのを省略できて、そのための煩雑な変数を導入せずに、フィクスチャとしてモデルのデータと関係性を記述する方法を考えて、fixtureパッケージとして実装することにしました。

fixtureパッケージを説明するために、前の例をfixtureパッケージを使って書き直したのが、次のコードです。

func TestListBooksByAuthor(t *testing.T) {
    f := fixture.Build(t,
        fixture.Library(func(l *model.Library) {
            l.Name = "ほげ図書館",
        }).Connect(
            fixture.Book(func(b *model.Book) {
            b.Name = "吾輩は猫である",
            }).Connect(
                modeltest.Author(func(a *model.Author) {
                    a.Name = "夏目漱石"
                })
            )
        ),
        fixture.Library(func(l *model.Library) {
            l.Name = "ぴよ図書館",
        }).Connect(
            fixture.Book(func(b *model.Book) {
                b.Name = "河童",
            }).Connect(
                fixture.Author(func(a *model.Author) {
                    a.Name = "芥川龍之介"
                })
            )
        )
    )
    f.Setup(t)
    // 以下テストコードが続く
}

LibraryID、BookID、AuthorIDを明示的に記述するコードはなくなり、モデル間の関係性をConnectと呼ばれるメソッドで接続しているのが分かるかと思います。

ではfixtureパッケージの中身を詳しく見ていきます。
まずFixture型を定義します。これはテストケースに必要なモデルのデータをすべてを持ちます。

package fixture

type Fixture struct {
    Libraries []*model.Library
    Books []*model.Books
    Authors []*model.Author
    BookAuthorMappings []*model.BookAuthorMappings
}

func (f *Fixture) Setup(t *testing.T) {
    // fの内容に基づいてテスト用にDBをセットアップする
}

次にModelConnector型を定義します。ModelConnector型は、モデル自体(Modelフィールド)と、そのモデルに関連のあるモデルをどのように接続するかを定義する関数(connectフィールド)を持ちます。

ModelConnector型のConnectメソッドは、上の例でもあった通りModelConnectorとModelConnectorを接続するメソッドです。モデルとモデルをどう繋げるのかはそれぞれに依存するので、connectフィールドに具体的な接続方法を定義する形になります。

addToFixtureAndConnectメソッドは実際にフィクスチャを作成するときに使われる内部のメソッドで、フィクスチャにデータを追加しつつConnectされたModelConnectorを引数にconnectを呼び出します。
さらに、ConnectされたModelConnectorのaddToFixtureAndConnectも再帰的に呼び出します。

addToFixtureAndConnectメソッドでは1度フィクスチャに追加されたModelConnectorについては処理をスキップするようになっていますが、この処理がある理由は後に明らかになります。

package fixture

type ModelConnector struct {
    Model interface{}

    // 定義されるべきコールバック
    addToFixture func(t testing.T, f *Fixture)
    connect      func(t testing.T, f *Fixture, connectingModel interface{})

    // 状態
    addedToFixture bool
    connectings []*ModelConnector
}

func (mc *ModelConnector) Connect(connectors ...*ModelConnector) *ModelConnector {
    mc.connectings = append(mc.connectings, connectors...)
    return mc // メソッドチェーンで記述できるようにする
}

func (mc *ModelConnector) addToFixtureAndConnect(t *testing.T, fixture *Fixture) {
    if mc.addedToFixture {
        return
    }

    if mc.addToFixture == nil {
        // addToFixtureは必ずセットされている必要がある
        t.Fatalf("addToFixture field of %T is not properly initialized", mc.Model)
    }
    mc.addToFixture(t, fixture)

    for _, modelConnector := range mc.connectings {
        if mc.connect == nil {
            // どのモデルとも接続できない場合はconnectをnilにできる
            t.Fatalf("%T cannot be connected to %T", modelConnector.Model, mc.Model)
        }

        mc.connect(t, fixture, modelConnector.Model)

        modelConnector.addToFixtureAndConnect(t, fixture)
    }

    mc.addedToFixture = true
}

次に、それぞれのテーブルごとに、このModelConnectorを返す関数を定義します。
connectフィールドに、それぞれの接続方法を記述します。

func Library(setter func(library *model.Library)) *ModelConnector {
    library := modeltest.Library(setter)
    return &ModelConnector{
        Model: library,
        addToFixture: func(t *testing.T, f *Fixture) {
            f.Libraries := append(f.Libraries, library)
        }
        connect: func(t *testing.T, f *Fixture, connectingModel interface{}) {
            // LibraryはBookのみ接続できる
            switch connectingModel := connectingModel.(type) {
                case *model.Book:
                    connectingModel.LibraryID = library.LibraryID

                default:
                    t.Fatalf("%T cannot be connected to %T", connectingModel, library)
            }
        }
    }
}

func Book(setter func(library *model.Book)) *ModelConnector {
    book := modeltest.Library(setter)
    return &ModelConnector{
        Model: book,
        addToFixture: func(t *testing.T, f *Fixture) {
            f.Books := append(f.Books, book)
        }
        connect: func(t *testing.T, f *Fixture, connectingModel interface{}) {
            // BookはAuthorのみ接続できる
            switch connectingModel := connectingModel.(type) {
                case *model.Author:
                    // BookとAuthorはマッピングによってリレーションが張られる
                    f.BookAuthorMappings = append(f.BookAuthorMappings, modeltest.BookAuthorMapping(func(bam *model.BookAuthorMapping) {
                        bam.BookID = book.BookID
                        bam.AuthorID = connectingModel.AuthorID
                    }))

                default:
                    t.Fatalf("%T cannot be connected to %T", connectingModel, book)
            }
        }
    }
}

// その他のモデルについても同様に定義する

最後に、Buildメソッドを定義します。
このBuildメソッドは引数として渡されたModelConnectorのaddToFixtureAndConnectを呼び出します。

func Build(t *testing.T, modelConnectors ...*ModelConnector) *Fixture {
    fixture := &Fixture{}

    for _, modelConnector := range modelConnectors {
        modelConnector.addToFixtureAndConnect(t, fixture)
    }

    return fixture
}

共通のデータをConnectする場合

以下のように複数のデータに対して共通するデータをConnectする場合は工夫が必要になります。

func TestListBooksByAuthor(t *testing.T) {
    f := fixture.Build(t,
        fixture.Library(func(l *model.Library) {
            l.Name = "ほげ図書館",
        }).Connect(
            fixture.Book(func(b *model.Book) {
                b.Name = "吾輩は猫である",
            }).Connect(
                fixture.Author(func(a *model.Author) {
                    a.Name = "夏目漱石"
                })
            )
            fixture.Book(func(b *model.Book) {
                b.Name = "こころ",
            }).Connect(
                fixture.Author(func(a *model.Author) {
                    // このAuthorは上のAuthorと同じであって欲しいが、
                    // 名前が同じ別のAuthorになってしまっている!
                    a.Name = "夏目漱石"
                })
            )
        )
    )
    f.Setup(t)
    // 以下テストコードが続く
}

この場合は、共通のデータを変数にすることで、うまく記述できるようになります。

func TestListBooksByAuthor(t *testing.T) {
    author := fixture.Author(func(a *model.Author) {
        a.Name = "夏目漱石"
    })
    f := fixture.Build(t,
        fixture.Library(func(l *model.Library) {
            l.Name = "ほげ図書館",
        }).Connect(
            fixture.Book(func(b *model.Book) {
                b.Name = "吾輩は猫である",
            }).Connect(author),
            fixture.Book(func(b *model.Book) {
                b.Name = "こころ",
            }).Connect(author) // 同じauthor
        )
    )
    f.Setup(t)
    // 以下テストコードが続く
}

この例でauthorは2回Connectされているので2回Fixtureに追加されてしまいそうですが、ModelConnectorのaddToFixtureAndConnectメソッドの実装を読んでもらうと分かる通り、1回だけ追加されるようになっています。

まとめ

プログラミング言語の構文は基本的に木構造をしています。木として表現できるモデルの関係性をコードの構造に対応させて記述できるようにすることで、比較的見やすくフィクスチャを書くことができました。
木として表現できない、つまり共通のデータを接続する必要がある際も、変数を導入してうまく書くことができます。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加