つきなみGo

sqlcとdockertestでデータベースを使ったテストを書こう

Goにおけるデータベース操作とテスト

Goでデータベースを操作する際には、標準パッケージであるdatabase/sqlGORMentなどの様々な選択肢が存在します。多くのライブラリでは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.sqlとクエリ定義ファイルquery.sqlの2つのファイルをもとにコード生成を行います。今回準備したファイルは以下のとおりです。

コード1 schema.sql
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL,
  age INT NOT NULL
);
コード2 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.sqlの例のようにクエリに特定のコメント-- name: CreateUser :oneを追加します。ここではname:のあとに続くのが関数名であり、:one:execが関数の種類を示しています。例えば、:oneならばクエリからの返り値を一つ持つことを示しています。

ファイル構成は以下のようになっています。sqlc.yamlにはoutputディレクトリに出力するように設定しました。公式ドキュメントのチュートリアルも是非参考にしてください。

├── query.sql
├── schema.sql
├── sqlc.yaml
└── output
    ├── db.go
    ├── models.go
    └── query.sql.go

sqlc generateを実行してコード生成を行った結果は以下のようになります。

コード3 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,
	}
}

コード4 model.go
package db

import ()

type User struct {
	ID    int32
	Name  string
	Email string
	Age   int32
}
コード5 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.goにマッピングされ、データベースを操作する関数がquery.goQueriesのメソッドとして定義されています。sqlc はコード生成時に、SQLファイルをコンパイルしています。例えば、GetUserの引数のid int32のように、sqlcはスキーマのファイルから推論を行って型を設定しています。

また、db.goに出力されたインターフェースDBTXをdatabase/sqlパッケージの*sql.DBpgxパッケージの*pgx.Connは満たしているので、データベースとの接続を行うライブラリの選択肢も豊富です。

トランザクション

sqlcを使ってデータベースの簡単な操作が行えることは確認できました。一歩進んだ項目として、sqlcではトランザクションを利用した処理を書くこともできるので見てみましょう。

sqlcではWithTXメソッドを利用することで、クエリをトランザクションの中で実行できます。あるユーザーの年齢をデータベースから読み出した後、1つ加算して更新する例を考えてみます。

コード6
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.Begin(ctx)で発行したトランザクションはtx.Commit(ctx)でコミットされます。

このように、トランザクションも簡単に実装できます。

sqlc+dockertest でデータベースを使ったテスト

Goにはuber-go/mock等の優れたパッケージがあり、アプリケーションのテストを書く際にデータベースをモックする場合も多いです。しかし、本番とできるだけ同じ条件でテストを動かしたい場合や、モック自体のメンテナンスコストが課題となることもあります。

この記事ではdockertestを使ってテストコードからPostgreSQLのDockerコンテナを立ち上げ、sqlcと組み合わせたテストの例を紹介します。

コード7 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: "test@test.com",
		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.dにスキーマファイルをマウントすると、コンテナ起動時に反映されます。TestUpdateUserAgesWithTransactionでは前節で紹介した年齢を1つ増やす関数をテストしています。

設定ファイルなどの記載は必要なく、テストの実行もとても簡単です。

% go test
PASS
ok      dockertest-sqlc-test-sample     3.307s

この記事で紹介した例はこちらのリポジトリからも参照いただけます。

おわりに

この記事では、Go言語でのデータベース操作とテストに焦点を当て、sqlcdockertestを組み合わせたテスト手法について紹介しました。データベースとのやりとりをシンプルかつ堅牢に行うためのsqlc、そして本番環境にできるだけ近い状態でテストを行うためのdockertestは、非常に強力なツールです。

テストはアプリケーションの品質を確保するために不可欠であり、本記事で紹介した手法では、実際のデータベースを利用したテストを容易に行うことができます。これによ本番環境と同じ条件での挙動をテストすることができます。

是非、これらのツールを活用して、より信頼性の高いソフトウェアを構築してください。

参考文献

おすすめ記事

記事・ニュース一覧