Goにおけるデータベース操作とテスト
Goでデータベースを操作する際には、標準パッケージであるdatabase/
、GORM、entなどの様々な選択肢が存在します。多くのライブラリではGoのコードを定義してSQLを生成しますが、sqlcはSQLをコンパイルしてGoのコードを生成するのが特徴のライブラリです。
このアプローチには、最終的に実行されるSQLが明らかであることやデータベースとやりとりするためのデータ構造を自分で定義する必要がないことといったメリットがあります。また、コンパイル時にSQLを解析し型や引数名の間違いを検出できます。そしてなにより、非常にシンプルです。
本記事では、sqlcの一歩進んだ使い方としてdockertestと組み合わせたテストの書き方について紹介します。dockertestとは、Dockerコンテナを立ち上げてテストを実行するための使いやすいコマンドを提供するパッケージです。Webアプリケーションのテストを行う際に、dockertestを利用することでデータベースをDockerコンテナとして用意できます。
なお、sqlcは現在MySQL、PostgreSQLとSQLiteをサポートしていますが、本記事ではPostgreSQLを使って解説します。
sqlcにはブラウザ上で気軽に振る舞いを確かめられるplaygroundがあります。適宜使用してみてください。
sqlcの使い方
基本的な使い方
まず、sqlcの特徴をざっくりと掴むために簡単なスキーマ定義と、書き込み読み込みを行うような例を紹介します。
sqlcではスキーマ設定ファイルschema.
とクエリ定義ファイルquery.
の2つのファイルをもとにコード生成を行います。今回準備したファイルは以下のとおりです。
schema.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
age INT NOT NULL
);
query.sql
-- name: GetUser :one
SELECT * FROM users
WHERE id = $1;
-- name: CreateUser :one
INSERT INTO users (
name, email, age
) VALUES (
$1, $2, $3
)
RETURNING *;
-- name: UpdateUserAges :exec
UPDATE users SET age = $2
WHERE id = $1;
この例では、ユーザーの参照と追加、そしてデータベースに保存されている年齢の更新を行うことができます。
sqlcではデータベースを操作する関数を定義するために、query.
の例のようにクエリに特定のコメント-- name: CreateUser :one
)name:
のあとに続くのが関数名であり、:one
や:exec
が関数の種類を示しています。例えば、:one
ならばクエリからの返り値を一つ持つことを示しています。
ファイル構成は以下のようになっています。sqlc.
にはoutputディレクトリに出力するように設定しました。公式ドキュメントのチュートリアルも是非参考にしてください。
├── query.sql
├── schema.sql
├── sqlc.yaml
└── output
├── db.go
├── models.go
└── query.sql.go
sqlc generate
を実行してコード生成を行った結果は以下のようになります。
db.go
package db
import (
"context"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}
model.go
package db
import ()
type User struct {
ID int32
Name string
Email string
Age int32
}
query.go
package db
import (
"context"
)
const createUser = `-- name: CreateUser :one
INSERT INTO users (
name, email, age
) VALUES (
$1, $2, $3
)
RETURNING id, name, email, age
`
type CreateUserParams struct {
Name string
Email string
Age int32
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRow(ctx, createUser, arg.Name, arg.Email, arg.Age)
var i User
err := row.Scan(
&i.ID,
&i.Name,
&i.Email,
&i.Age,
)
return i, err
}
const getUser = `-- name: GetUser :one
SELECT id, name, email, age FROM users
WHERE id = $1
`
func (q *Queries) GetUser(ctx context.Context, id int32) (User, error) {
row := q.db.QueryRow(ctx, getUser, id)
var i User
err := row.Scan(
&i.ID,
&i.Name,
&i.Email,
&i.Age,
)
return i, err
}
const updateUserAges = `-- name: UpdateUserAges :exec
UPDATE users SET age = $2
WHERE id = $1
`
type UpdateUserAgesParams struct {
ID int32
Age int32
}
func (q *Queries) UpdateUserAges(ctx context.Context, arg UpdateUserAgesParams) error {
_, err := q.db.Exec(ctx, updateUserAges, arg.ID, arg.Age)
return err
}
playgroundからもこの結果を確認できます。
データベースの構造がmodel.
にマッピングされ、データベースを操作する関数がquery.
にQueries
のメソッドとして定義されています。sqlc はコード生成時に、SQLファイルをコンパイルしています。例えば、GetUser
の引数のid int32
のように、sqlcはスキーマのファイルから推論を行って型を設定しています。
また、db.
に出力されたインターフェースDBTXをdatabase/
パッケージの*sql.
やpgx
パッケージの*pgx.
は満たしているので、データベースとの接続を行うライブラリの選択肢も豊富です。
トランザクション
sqlcを使ってデータベースの簡単な操作が行えることは確認できました。一歩進んだ項目として、sqlcではトランザクションを利用した処理を書くこともできるので見てみましょう。
sqlcではWithTX
メソッドを利用することで、クエリをトランザクションの中で実行できます。あるユーザーの年齢をデータベースから読み出した後、1つ加算して更新する例を考えてみます。
func IncrementUserAges(ctx context.Context, conn *pgx.Conn, q *db.Queries, id int32) error {
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
qWithTx := q.WithTx(tx)
u, err := qWithTx.GetUser(ctx, id)
if err != nil {
return err
}
err = qWithTx.UpdateUserAges(ctx, db.UpdateUserAgesParams{
ID: u.ID,
Age: u.Age + 1,
})
if err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
return nil
}
この例ではGetUser
でユーザーの年齢を読み出し、UpdateUserAges
で年齢を更新しています。conn.
で発行したトランザクションはtx.
でコミットされます。
このように、トランザクションも簡単に実装できます。
sqlc+dockertest でデータベースを使ったテスト
Goにはuber-go/
この記事ではdockertestを使ってテストコードからPostgreSQLのDockerコンテナを立ち上げ、sqlcと組み合わせたテストの例を紹介します。
main_test.go
var q *db.Queries
var conn *pgx.Conn
func TestMain(m *testing.M) {
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("Could not construct pool: %s", err)
}
pwd, _ := os.Getwd()
resource, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "postgres",
Env: []string{
"POSTGRES_PASSWORD=secret",
"POSTGRES_USER=user_name",
"POSTGRES_DB=dbname",
"listen_addresses = '*'",
},
Mounts: []string{
// docker-entrypoint-initdb.dにschema.sqlをマウントすると、コンテナ起動時に反映される
fmt.Sprintf("%s/schema.sql:/docker-entrypoint-initdb.d/schema.sql", pwd),
},
}, func(config *docker.HostConfig) {
// 終了時にコンテナを削除する
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
})
if err != nil {
log.Fatalf("Could not start resource: %s", err)
}
dbPath := fmt.Sprintf("postgres://user_name:secret@%s/dbname?sslmode=disable", resource.GetHostPort("5432/tcp"))
if err := pool.Retry(func() error {
conn, err = pgx.Connect(context.Background(), dbPath)
if err != nil {
return err
}
// 接続が確立されているかを確認する
if conn.Ping(context.Background()); err != nil {
return err
}
q = db.New(conn)
return nil
}); err != nil {
log.Fatalf("Could not connect to database: %s", err)
}
code := m.Run()
if err := pool.Purge(resource); err != nil {
log.Fatalf("Could not purge resource: %s", err)
}
os.Exit(code)
}
func TestUpdateUserAgesWithTransaction(t *testing.T) {
// ユーザーを作成
u, err := q.CreateUser(context.Background(), db.CreateUserParams{
Name: "test",
Email: "[email protected]",
Age: 20,
})
if err != nil {
t.Fatal(err)
}
// 年齢を+1する関数を実行
err = IncrementUserAges(context.Background(), conn, q, u.ID)
if err != nil {
t.Fatal(err)
}
// 年齢が+1されていることを確認
q = db.New(conn)
u, err = q.GetUser(context.Background(), u.ID)
if err != nil {
t.Fatal(err)
}
if u.Age != 21 {
t.Fatalf("expected age to be 21, got %d", u.Age)
}
}
TestMain
では、dockertestを利用してpostgresのDockerコンテナを起動しています。docker-entrypoint-initdb.
にスキーマファイルをマウントすると、コンテナ起動時に反映されます。TestUpdateUserAgesWithTransaction
では前節で紹介した年齢を1つ増やす関数をテストしています。
設定ファイルなどの記載は必要なく、テストの実行もとても簡単です。
% go test PASS ok dockertest-sqlc-test-sample 3.307s
この記事で紹介した例はこちらのリポジトリからも参照いただけます。
おわりに
この記事では、Go言語でのデータベース操作とテストに焦点を当て、sqlc
とdockertest
を組み合わせたテスト手法について紹介しました。データベースとのやりとりをシンプルかつ堅牢に行うためのsqlc
、そして本番環境にできるだけ近い状態でテストを行うためのdockertest
は、非常に強力なツールです。
テストはアプリケーションの品質を確保するために不可欠であり、本記事で紹介した手法では、実際のデータベースを利用したテストを容易に行うことができます。これによ本番環境と同じ条件での挙動をテストすることができます。
是非、これらのツールを活用して、より信頼性の高いソフトウェアを構築してください。