go test で PostgreSQL が使いたい ... !!!
はじめに
皆さんは SQL を理解しているでしょうか。または ORM が生成する SQL を正しく把握しているでしょうか。私は自信がありません。そのため、実際にテストを実行して動作を確認したいです。
PostgreSQL を使ったアプリケーションを開発する際、テストを実行するときにも PostgreSQL を使いたくなりますよね。
今回は go test コマンドにてテストを実行する際に PostgreSQL を使う方法についてご紹介します。
PostgreSQL の構文に準拠し、実際に動作することを確認したいため SQLite や go-sqlmock などのライブラリは対象外としています。
サンプルテストケース
PostgreSQL を使ったテストケースは以下の流れを想定します。
- PostgreSQL の起動
- マイグレーション
- INSERT
- SELECT
- UPDATE
- DELETE
- SELECT(NotFound)
// PostgreSQL の起動
// マイグレーション
// PostgreSQL へ接続
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
var id string
// INSERT
if err := db.QueryRowContext(t.Context(), `INSERT INTO samples (name) VALUES ($1) RETURNING id`, "test").Scan(&id); err != nil {
t.Fatal(err)
}
// SELECT
if err := db.QueryRowContext(t.Context(), `SELECT id FROM samples WHERE id = $1`, id).Scan(&id); err != nil {
t.Fatal(err)
}
// UPDATE
if _, err := db.ExecContext(t.Context(), `UPDATE samples SET name = $1 WHERE id = $2`, "updated", id); err != nil {
t.Fatal(err)
}
// DELETE
if _, err := db.ExecContext(t.Context(), `DELETE FROM samples WHERE id = $1`, id); err != nil {
t.Fatal(err)
}
// SELECT
if err := db.QueryRowContext(t.Context(), `SELECT id FROM samples WHERE id = $1`, id).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return
}
t.Fatal(err)
}
4つの実装方法
- 外部起動(Dockerなど)の利用
- Testcontainers の利用
- fergusstrange/embedded-postgres の利用
- proullon/ramsql の利用
比較する観点
それぞれの実装方法において以下の観点を比較します。
- 起動 ~ 接続までの実装
- マイグレーション実行方法
- CI 環境
- PostgreSQL のバージョン管理
外部起動(Dockerなど)の利用
アプリケーション実装と同じで PostgreSQL は起動しているものとして接続します。
そのためテストコード外で PostgreSQL の起動およびマイグレーションは実施済みの想定です。
起動 ~ 接続までの実装
外部で起動した PostgreSQL への接続情報をなんらかの形で設定し接続するだけです。
以下は database/sql および lib/pq を利用した接続です。
dsn := cmp.Or(os.Getenv("DSN"), "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable")
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
if err := db.PingContext(t.Context()); err != nil {
t.Fatal(err)
}
以下は github.com/jackc/pgx/v5 を利用した接続です。
dsn := cmp.Or(os.Getenv("DSN"), "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable")
conn, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
pool, err := pgxpool.NewWithConfig(t.Context(), conn)
if err != nil {
t.Fatal(err)
}
if err := pool.Ping(t.Context()); err != nil {
t.Fatal(err)
}
マイグレーション実行方法
事前に用意する前提のため任意の方法で準備できます。
CI 環境
CI においてもテストコード外で PostgreSQL の起動およびマイグレーションを実行する必要があります。
GitHub Actions を利用する場合は以下のようなコードとなります。
test:
services:
# PostgreSQL コンテナの起動
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# psql によるマイグレーション
- name: Initialize database schema
run: |
PGPASSWORD=postgres psql -h localhost -U postgres -d postgres -f schema/*.sql
- name: Run test
# ...
PostgreSQL のバージョン管理
例えば Docker Compose を利用している場合は compose.yaml ファイルなどでバージョン指定できます。
services:
postgres:
container_name: postgres
image: postgres:18-alpine
GitHub Actions も同様にバージョン指定ができます。
test:
services:
postgres:
image: postgres:18
Official のイメージを利用するのであればリリースサイクルに応じて最新のバージョンも利用可能となります。
Testcontainers の利用
コンテナを利用したテストを実行するためのライブラリを利用します。
Docker API 互換のコンテナランタイム(Docker Desktop など)が必要となります。
起動 ~ 接続までの実装
Testcontainers の機能を使って PostgreSQL のコンテナを起動します。
以下は database/sql および lib/pq を利用した接続です。
migrations, err := filepath.Glob(filepath.Join("../schema", "*.sql"))
if err != nil {
t.Fatal(err)
}
container, err := postgres.Run(
t.Context(),
"postgres:18-alpine",
postgres.WithInitScripts(migrations...),
testcontainers.WithWaitStrategy(
wait.ForAll(
wait.ForListeningPort("5432/tcp"),
wait.ForExec([]string{"pg_isready", "-U", "postgres", "-d", "postgres"}).
WithPollInterval(1*time.Second).
WithExitCodeMatcher(func(exitCode int) bool {
return exitCode == 0
}).
WithStartupTimeout(30*time.Second),
),
),
)
if err != nil {
t.Fatal(err)
}
testcontainers.CleanupContainer(t, container)
dsn, err := container.ConnectionString(t.Context(), "sslmode=disable")
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
if err := db.PingContext(t.Context()); err != nil {
t.Fatal(err)
}
以下は github.com/jackc/pgx/v5 を利用した接続です。
migrations, err := filepath.Glob(filepath.Join("../schema", "*.sql"))
if err != nil {
t.Fatal(err)
}
container, err := postgres.Run(
t.Context(),
"postgres:18-alpine",
postgres.WithInitScripts(migrations...),
testcontainers.WithWaitStrategy(
wait.ForAll(
wait.ForListeningPort("5432/tcp"),
wait.ForExec([]string{"pg_isready", "-U", "postgres", "-d", "postgres"}).
WithPollInterval(1*time.Second).
WithExitCodeMatcher(func(exitCode int) bool {
return exitCode == 0
}).
WithStartupTimeout(30*time.Second),
),
),
)
if err != nil {
t.Fatal(err)
}
testcontainers.CleanupContainer(t, container)
dsn, err := container.ConnectionString(t.Context(), "sslmode=disable")
if err != nil {
t.Fatal(err)
}
conn, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
pool, err := pgxpool.NewWithConfig(t.Context(), conn)
if err != nil {
t.Fatal(err)
}
if err := pool.Ping(t.Context()); err != nil {
t.Fatal(err)
}
マイグレーション実行方法
コンテナ初回起動時に1度だけデータベースの初期化を行える docker-entrypoint-initdb.d の仕組みを Testcontainers でも利用することができます。
マイグレーション用の SQL ファイルがあるディレクトリパスを渡してあげることで実現できます。
migrations, err := filepath.Glob(filepath.Join("../schema", "*.sql"))
if err != nil {
t.Fatal(err)
}
container, err := postgres.Run(
t.Context(),
"postgres:18-alpine",
//
postgres.WithInitScripts(migrations...),
testcontainers.WithWaitStrategy(
wait.ForAll(
wait.ForListeningPort("5432/tcp"),
wait.ForExec([]string{"pg_isready", "-U", "postgres", "-d", "postgres"}).
WithPollInterval(1*time.Second).
WithExitCodeMatcher(func(exitCode int) bool {
return exitCode == 0
}).
WithStartupTimeout(30*time.Second),
),
),
)
CI 環境
テストコードの中で環境構築も行われるので事前準備は不要です。
PostgreSQL のバージョン管理
Testcontainers 起動メソッドにてコンテナイメージを指定することができます。
container, err := postgres.Run(
t.Context(),
"postgres:18-alpine",
)
こちらも Official のイメージを利用するのであればリリースサイクルに応じて最新のバージョンが利用可能です。
fergusstrange/embedded-postgres の利用
PostgreSQL のバイナリをコード実行中にダウンロード・起動することができるライブラリを利用します。
起動 ~ 接続までの実装
embedded-postgres の機能を使って PostgreSQL を起動します。
以下は database/sql および lib/pq を利用した接続です。
cfg := embeddedpostgres.DefaultConfig()
database := embeddedpostgres.NewDatabase(cfg)
if err := database.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = database.Stop()
})
db, err := sql.Open("postgres", cfg.GetConnectionURL()+"?sslmode=disable")
if err != nil {
t.Fatal(err)
}
if err := db.PingContext(t.Context()); err != nil {
t.Fatal(err)
}
以下は github.com/jackc/pgx/v5 を利用した接続です。
cfg := embeddedpostgres.DefaultConfig()
database := embeddedpostgres.NewDatabase(cfg)
if err := database.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = database.Stop()
})
conn, err := pgxpool.ParseConfig(cfg.GetConnectionURL() + "?sslmode=disable")
if err != nil {
t.Fatal(err)
}
pool, err := pgxpool.NewWithConfig(t.Context(), conn)
if err != nil {
t.Fatal(err)
}
if err := pool.Ping(t.Context()); err != nil {
t.Fatal(err)
}
マイグレーション実行方法
起動後に実行する必要があります。
マイグレーションファイルをディレクトリから読み込み DDL を実行します。
Go で実行されているマイグレーションツールを利用しているのであればそのライブラリを直接実行することでマイグレーションも可能かと思います。
CI 環境
Testcontainers 同様、テストコードの中で環境構築も行われるので事前準備は不要です。
PostgreSQL のバージョン管理
PostgreSQL を起動するメソッドで Config にてバージョンを指定することが可能です。
ただし、指定できるバージョンも Config で選択肢が決まっています。
proullon/ramsql の利用
インメモリで SQL が実行できるライブラリを利用します。
明示的な記述はなさそうですが、PostgreSQL 特有の BIGSERIAL 型やパラメータプレースホルダー $1,$2... の形式をサポートしていることから PostgreSQL と互換性のある SQL エンジンとして設計されていることが伺えます。
起動 ~ 接続までの実装
RamSQL を使った PostgreSQL 接続は独自のドライバを利用する必要があります。
そのため database/sql を利用した実装しかできません。
import _ "github.com/proullon/ramsql/driver"
// ...
db, err := sql.Open("ramsql", "TestRamsql")
if err != nil {
t.Fatal(err)
}
マイグレーション実行方法
embedded-postgres 同様、DDL を実行する必要があります。
CI 環境
Testcontainers 同様、テストコードの中で環境構築も行われるので事前準備は不要です。
PostgreSQL のバージョン管理
PostgreSQL の互換性は担保しているもののバージョンに対するサポートはなさそうです。
実行時間の比較
ローカル環境(M1 Mac)
筆者の環境においてそれぞれのテスト実行時間について比較してみます。
実行環境のスペックは以下になります。
Chip: Apple M1
Total Number of Cores: 8 (4 performance and 4 efficiency)
Memory: 16 GB
なお、頻繁に実行することを条件として初回実行(コンテナやバイナリのダウンロード)については除外します。
以下に結果を記載します。
数字はあくまで参考値としてご確認ください。
| ramsql | 外部起動 | Testcontainers | embedded-postgres |
|---|---|---|---|
| 約 0.3 s | 約 0.4 s | 約 4 s | 約 12 s |
GitHub Actions
記事公開時点の ubuntu-latest(Ubuntu 24.04)[1] にて実行しています。
実行環境のスペックは以下になります。[2]
| Virtual machine | Processor (CPU) | Memory (RAM) | Storage (SSD) | Architecture |
|---|---|---|---|---|
| Linux | 4 | 16 GB | 14 GB | x64 |
なお、キャッシュなどの設定はせずに基本的な動作ができるように GitHub Actions は実装したものになります。
ローカル環境と異なり GitHub Actions 全体の実行時間を比較対象とします。
以下に結果を記載します。
こちらも数字はあくまで参考値としてご確認ください。
| ramsql | 外部起動 | Testcontainers | embedded-postgres |
|---|---|---|---|
| 約 15 s | 約 39 s | 約 27 s | 約 20 s |
要因まで深掘りできていませんが、GitHub Actions 環境だと embedded-postgres が2番目に早いことが確認できました。
また、ローカル環境では実行速度の早い外部起動の PostgreSQL を使った方法ですが、都度1から起動する必要があるため最も遅い結果となっています。
まとめ
それぞれの特徴についてまとめると以下になります。
| 観点 | 外部起動(Dockerなど) | Testcontainers | embedded-postgres | ramsql |
|---|---|---|---|---|
| PostgreSQL互換性 | 完全 | 完全 | 完全 | 高い (一部構文対応) |
| PostgreSQL バージョン | 自由 | 自由 (イメージ指定) | ライブラリ依存 (限定的) | 非対応 |
| 外部依存 | PostgreSQLサーバ | Dockerランタイム | なし (実行時DL) | なし (インメモリ) |
| マイグレーション | テスト外で事前実行 | 起動時 (InitScripts) | 起動後に手動実行 | 起動後に手動実行 |
| CIでの事前準備 | 必要 | 不要 | 不要 | 不要 |
| 実行速度(ローカル) | 速い | 遅い | 最も遅い | 最も速い |
| 実行速度(CI) | 最も遅い | やや遅い | やや速い | 最も速い |
| 主な特徴 | 実装がシンプル | 柔軟なテスト環境構築 | Docker不要 |
database/sql のみ対応 |
おわりに
go test で PostgreSQL を使う方法について紹介しました。
どの方法にも一長一短があるため、プロジェクトの要件(CI の実行速度、環境構築の手軽さ、PostgreSQL への依存度)に応じて最適な手法を選択することをおすすめします。
Discussion