DBを使うGoのテストを速く・壊れにくくする

ogp

はじめに

データベースを使った実装のテストは書くのもメンテナンスしていくのも面倒になりがちです。自分たちのチームでは当初モックをしていました。 しかしモックのメンテナンスコストが高いこと、テストの実装をよく分解してみると実は時間の経過と共にテストしたいところがテストできてないものになっていたことなどがありました。 そのため Testcontainers を使用してテスト用DBをセットアップ する環境へ切り替えました。

Testcontainersはローカルでのテスト実行にDockerを必要とし、コンテナの起動にかかる時間も無視できないことが、運用していく中で課題となってきました。

そこで自分たちが運用しているプロダクトではテストコードからは関数を1つ呼ぶだけで「マイグレーション済みの空のデータベース」が手に入る、という状態を目指してテスト用のヘルパーパッケージを整備しました。 本記事ではその設計と、実装する過程でのハマりどころについて紹介します。

設計の方針

このパッケージで提供したかったのは、おおまかには次の三点です。

  • テストコードから1行呼ぶだけでマイグレーション済みの空のデータベースが使える
  • 実行環境を見てPostgreSQLのプロセス起動とTestcontainersを自動で切り替える
  • テストごとに独立したDBを高速に生成し、終わったら片付ける

実行環境によってプロセス起動とTestcontainers起動を切り替えているのは主に実行速度のためです。 Testcontainers起動は(Testcontainersの支援により再利用されるとはいえ)どうしてもコンテナの起動のオーバーヘッドがあり実行速度の面で不利になります。 またコンテナが確実に起動するとも限らず、たまに失敗することがありました。 そこでプロセスを直接起動できる場合はそちらを優先して使用します。 この起動したプロセスを管理する部分も本パッケージで実装しています。

呼び出し側のコードはこのような形になります。

func TestSomething(t *testing.T) {
    pi, err := NewPostgresInstance()
    if err != nil {
        t.Fatal(err)
    }
    db := pi.GetDatabase(t)
    // db はマイグレーション済みのまっさらな *sql.DB
}

GetDatabaset.Cleanup を内側で呼んでおり、テストが終わった時点で接続のクローズとデータベースを削除します。テストを書く側はクリーンアップを意識する必要がありません。

最終的には以下のようにパッケージで1つのインスタンスを共有し各テストケースでDBを複製しています。

var pi *PostgresInstance

func TestMain(m *testing.M) {
    var err error
    pi, err = NewPostgresInstance()
    if err != nil {
        panic(err)
    }
    defer pi.Stop()

    m.Run()
}

func TestSomething(t *testing.T) {
    db := pi.GetDatabase(t)
}

func TestSomething2(t *testing.T) {
    db := pi.GetDatabase(t)
}

テンプレートDBによる高速化

数百〜数千あるテストケースのたびにマイグレーションを流していたのではスケールしません。一方で、マイグレーションの結果は毎回同じになるはずなのでこれはキャッシュして使い回せます。 PostgreSQLにはテンプレートデータベースという機能があり、既存のデータベースを雛形として新しいデータベースを作ることができるのでこれを使います。

具体的には、PostgreSQLのインスタンスを起動した 直後に マイグレーション済みのテンプレートDBを1つ作っておき、テストごとに次のSQLを発行します。

CREATE DATABASE <random_name> TEMPLATE <template_db>

これでテストごとに独立したデータベースが手に入ります。ファイルレベルでのコピーで済むので、マイグレーションを流すよりはるかに速いです。

データベース名は衝突を避けるためランダムに生成しています。 テスト内で t.Parallel() を呼んでも、それぞれのテストは別のDBに対して操作することになるので互いに干渉しません。

環境差を吸収する

開発者の手元とCIとでは、PostgreSQLを動かす方法が異なります。macOSではHomebrewやMacPortsで入れたPostgreSQLを直接プロセスとして起動することもできます。Linuxではディストリビューションが提供するスクリプトを使用する流儀があります。CI(GitHub Actions)はUbuntuベースでディストリビューションが提供するスクリプトを使う必要があるのでこれらの環境差を吸収しています。

これらをテストコードから意識せずに済むように、起動時に環境を判定して起動方法を切り替えています。

var checkInstallPostgresLocally sync.Once
checkInstallPostgresLocally.Do(func() {
    _, err := exec.LookPath("pg_ctl")
    if err == nil {
        canUseLocalPostgres = true
    }
    _, err = exec.LookPath("pg_createcluster")
    if err == nil {
        canUseLocalPostgres = true
        runningOnDebian = true
    }
})

macOSでのプロセス起動

macOSでは pg_ctl 経由で起動します。一時ディレクトリにdataディレクトリを作って pg_ctl init を流し、続けて pg_ctl start で立ち上げます。ポートは衝突を避けるためランダムに決めていて、net.Listen("tcp", ":0") で確保したポートをそのまま使っています。

Debian / GitHub Actionsでの起動

Debian系では pg_createcluster を使うのが標準的なやり方になっており、GitHub ActionsのUbuntuランナーもこれに該当します。ただランナーは非特権で動いているため、pg_createclusterpg_dropclustersudo 経由で呼び出す必要があります。

func (*processManagerForDebian) runCommand(name string, args ...string) error {
    var cmd *exec.Cmd
    if RunningOnGitHubRunner() {
        cmd = exec.Command("sudo", append([]string{name}, args...)...)
    } else {
        cmd = exec.Command(name, args...)
    }
    // ...
}

ランナー上で動作しているかどうかを判定するには RUNNER_ENVIRONMENT 環境変数を見ています。GitHub hostedランナーでは github-hosted がセットされます。

Testcontainersでの起動

Dockerしかない環境ではTestcontainersを使います。postgres:18 のイメージをそのまま使えればよいのですが、自分たちのプロジェクトでは pg_bigm 拡張を使っており公式イメージのままでは足りません。そのためDockerfileを動的に生成し、testcontainers.FromDockerfile でビルドして起動しています。 (このためにTestcontainers起動だと初回の起動が更に遅くなるというのもあります)

container, err := postgres.Run(ctx,
    "", // Dockerfile からイメージを作成するため Image を指定しない
    postgres.WithDatabase(databaseName),
    postgres.WithUsername(username),
    postgres.WithPassword(password),
    testcontainers.CustomizeRequest(testcontainers.GenericContainerRequest{
        ContainerRequest: testcontainers.ContainerRequest{
            FromDockerfile: testcontainers.FromDockerfile{
                Context: c.tempDir,
            },
            Env: map[string]string{
                "LANG": "en_US.UTF-8",
            },
        },
    }),
    // ...
)

ハマりどころ

panic時にPostgreSQLのプロセスが残る

ローカルでテストを動かしているときにプロセスとして起動したPostgreSQLがテスト終了後も生き残ってしまうことがありました。特に t.Parallel() で並列化した複数のテストのうち1つがpanicしたときに発生します。

t.Cleanup の中でテストに使用したDBをドロップし最終的には TestMain のdeferでPostgreSQLを停止しようとしているのですが並列化されたテストでpanicすると TestMain のdeferは呼ばれません。また起動したpostgresは別のプロセスグループになってしまうためテストプロセスの終了と切り離されてゾンビ化します。 この緩和策として、テストの失敗を検知できた場合には他のテストの完了をある程度待ってからPostgreSQLを停止する、という処理を入れています。完璧ではありませんがゾンビプロセスが残る頻度は明らかに減りました。 テストケースの失敗を検知した時点で他のテストが動作中でもPostgreSQLを強制的に停止して動作中のテストが失敗になっても結果としてはそれほど変わりません。どちらにしてもテストとしては失敗となります。しかしテストの序盤で強制的な停止が発生し多くのテストが失敗した場合に本来の失敗原因が見つけづらくなります。そこで他のテストの完了をある程度待っています。

t.Cleanup(func() {
    // ...
    if testFailed(t) && !RunningOnGitHubRunner() {
        // 他のテストが終わるのをある程度待ってから DB を落とす
        ticker := time.NewTicker(time.Second)
        timeout := time.After(30 * time.Second)
    Check:   
        for {
            select {
            case <-ticker.C:
                // Waiting for finished other tests.
                l := len(pi.inUseDB)   
                if l == 0 {
                    if err := pi.Stop(); err != nil {
                        fmt.Fprintf(os.Stderr, "Failed to stop database: %v\n", err)
                    }
                    break Check
                }
            case <-timeout:
                fmt.Fprintln(os.Stderr, "Timed out waiting for close all databases.")
                break Check
            }
        }
    }
})

GitHub Actionsではrunnerごと使い捨てられるのでこの処理は走らせていません。

t.Failed() がpanic時にfalseを返す

「テストの失敗を検知できた場合には」と前節で書きましたが、これがそもそも素直にいきません。testing.T.Failed() はpanicで失敗させたテストに対して false を返すことがあります。これはGoの標準ライブラリ側のバグとして golang/go#49929 で報告されています。

そのため別の方法でpanicを検知する必要があります。少々ダーティーですが testing.commonfinished フィールドをreflectで覗き、テスト本体が走ったにも関わらずfinishedになっていなければpanicした、と判定しています。

func testFailed(t testingT) bool {
    if t.Failed() {
        return true
    }
    v := reflect.ValueOf(t).Elem()
    cm := v.FieldByName("common")
    finished := cm.FieldByName("finished").Bool()
    if !finished {
        return true
    }
    return false
}

データベースの状態を適切に観測する必要がある

PostgreSQLのプロセスやコンテナを起動した直後はまだDBとして接続できないこともあります。 そのため起動したかどうかを観測し、データベースが機能するようになってからテンプレートDBの作成・マイグレーションを行うといったことが必要になります。

これもプロセス起動とコンテナ起動によって若干方法が異なるのでその差を吸収する必要があります。

プロセス起動の場合は確保したポートに対して接続し sql.DB.Ping() が成功するまで待ちます。

for {
    conn, err := sql.Open("postgres", fmt.Sprintf("host=127.0.0.1 port=%d user=postgres dbname=template1 sslmode=disable", p.Port))
    if err != nil {
        time.Sleep(100 * time.Millisecond)
        continue
    }
    if err := conn.Ping(); err == nil {
        conn.Close()
        break
    }
    time.Sleep(100 * time.Millisecond)
}

一方コンテナ起動の場合はTestcontainersライブラリのサポートを受けることができます。

container, err := postgres.Run(ctx,
    "", // Dockerfile からイメージを作成するため Image を指定しない
    // ...
    testcontainers.WithWaitStrategy(
        wait.ForExposedPort(),
        wait.ForLog("database system is ready to accept connections").AsRegexp().
            WithStartupTimeout(10*time.Second),
    ),
)

さらなる高速化:プロセスの再利用

ここまでの仕組みでテストはだいぶ速くなりました。しかしまだもう少しテストを高速化する余地が残っています。 Goのテストはパッケージ単位で別プロセスとして実行されるため、前述までの仕組みだとパッケージごとにPostgreSQLの初期化・起動が行われてしまいます。テスト対象のパッケージが数十・数百になってくると、起動待ちの時間がそれなりに積み上がってきます。

テストにデータベースを使用しているがテストケースが少ないパッケージだと以下のような時間がかかっていました。

パッケージ 通常の実行時間
github.com/enechain/product/internal/A 8s
github.com/enechain/product/internal/B 8s
github.com/enechain/product/internal/C 7s

ローカルで少数のテストを実行する際にはそこまでは気になりませんがCIでテスト全体を実行する場合はPostgreSQLの初期化・起動のステップが何回も行われることになりその無駄が気になってきます。

そこでGitHub Actions上ではPostgreSQLを一度起動したら使い回す、という最適化を行っています。テスト全体を実行する前に専用のコマンドでPostgreSQLを起動し、テンプレートDBまで作り終えた状態にしておきます。プロセスの情報はJSONファイルに書き出し、そのパスを環境変数で各テストプロセスに伝えます。テスト側は環境変数を見て、すでに起動しているプロセスがあるならそれを利用します。

type postgresProcessInformation struct {
    Port     int   `json:"port"`
    Name     string `json:"name"`
    Username string `json:"username"`
    Password string `json:"password"`
}

if workDir := os.Getenv(WorkingDirEnvKey); workDir != "" && allowReuseProcess {
    manager := newProcessManagerForReuse()
    buf, err := os.ReadFile(filepath.Join(workDir, processInformationFilename))
    // ...
    var info postgresProcessInformation
    if err := json.Unmarshal(buf, &info); err != nil {   
        return nil, err
    }
    return info, nil
}

このような再利用できる仕組みを入れておき以下のようにGitHub Actionsのワークフローでテストを実行する前にPostgreSQLを準備しています。

jobs:  
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout
      - run: |- # このステップで再利用される PostgreSQL を起動する
          go run ./cmd/test-postgresql >> $GITHUB_ENV
      - name: Run unit tests
        run: go test ./... # 決められたフォーマットの JSON が出力されたディレクトリが環境変数に設定されているので再利用される

※プロセスを再利用している場合はteardownを意図的に行っていません。GitHub Actionsのrunnerはジョブが終われば破棄されるので、明示的に止める意味がないからです。

このようにプロセスの再利用をしたところ上記のようなパッケージのテストは大幅に高速化しました。

パッケージ 通常の実行時間 再利用した場合の実行時間
github.com/enechain/product/internal/A 8s 247ms 3%
github.com/enechain/product/internal/B 8s 353ms 4%
github.com/enechain/product/internal/C 7s 246ms 3.5%

やってみての所感

書いてみてよかったと感じている点は次のあたりです。

  • テストを書く側は何も考えなくていい。pi.GetDatabase(t) を呼ぶだけで、その先のテストは普通のSQLを投げるだけで済む
  • 手元とCIで同じ抽象化に乗っているので「CIでだけ落ちる」が起きにくい
  • 並列テストでもデータが汚れない
  • flakyテストが起きづらくなった

一方で残っている課題もあります。

  • t.Failed() のワークアラウンドでreflectに依存しているので、Go側の修正が入れば壊れる可能性がある
  • それでもゾンビプロセスは稀に生まれることがある
  • 実DBを使う以上、テストに必要な事前データは自分で用意しなければならない

まとめ

「テスト用DBをどう用意するか」というのは地味な話ですが、開発体験とCIの安定性に効いてくるところでもあります。環境ごとの差を抽象化したうえで、テンプレートDBによるクローン生成と片付けの自動化を組み合わせることで、テストを書く側にDB周りの面倒を見せずに済んでいます。

実DBを使うと「テストに必要なデータをどう構築するか」という別の問題が出てきますが、それについてはまた別の記事で書きたいと思います。


enechainでは、一緒に事業を拡大していける仲間を募集中です。