この章で行うこと
- リポジトリの実装
- ORMとしてsqlcを導入
- リポジトリのテスト実装
リポジトリの責務
リポジトリは、オブジェクトの永続化(保存)や再構築(復元)といったデータストア操作を担う役割を果たします。この「データストアの操作」をリポジトリという専用のコンポーネントに切り出すことで、ビジネスロジックと分離され、責務が明確化されます。
たとえば、今回使用するデータストアは MySQL と Redis ですが、ドメインロジックはその「詳細」を知る必要はありません。RDBであろうと NoSQLであろうと、「データを保存・取得する」という役割を果たせれば十分です。リポジトリを介することで、データストアの種類を抽象化し、柔軟かつ一貫した方法で操作できるようになります。

リポジトリの実装方針
リポジトリは、データ操作を抽象的に扱うためのものだという話を上で述べました。
このセクションで重要なのは、ORMの扱いです。 Redisは一旦さておき、MySQLなどRDBへの操作は、ORMを用いた方が便利なパターンが多いと思います。
ORM(Object-Relational Mapping)
ORMとは、オブジェクト指向プログラミングのクラスとリレーショナルデータベースのテーブルとの間でデータの変換を自動化する技術です。golangの場合だと、クラス=構造体と読み替えることができます。これにより、SQLを直接記述することなく、プログラム内のオブジェクトをデータベースの行に対応させて操作できるようになります。データベースとのやり取りは簡素化し、扱いやすくなります。
ORMにはさまざまな種類があります。
ここで強調したいのは、ORMは「詳細」であるということです。実装でもインフラストラクチャ層に配置します。今回はsqlcを採用しますが(それに関しても後述)、例えばgormにも簡単に乗り換えられる柔軟性を持たせることは重要です。それを実現するために必要なことは、リポジトリをORMの具体型ではなく抽象型、つまりインターフェースに依存させることです。第5章では抽象型は「安定」しているということを述べました。
それに、詳細としてインフラストラクチャ層に配置するORMにインターフェースアダプター層のリポジトリが依存してしまっては依存性のルールにも反してしまいますね。
境界線を引く
余談ですが、書籍ではインターフェースによって「境界線を引く」という表現がされています。
ソフトウェアアーキテクチャとは、境界線を引く技芸である。これを私は「バウンダリー」と呼んでいる。ソフトウェアの要素を分離し、お互いのことがわからないように制限するというものである。
Robert C.Martin; 角 征典; 高木 正弘. Clean Architecture 達人に学ぶソフトウェアの構造と設計 (アスキードワンゴ) (p.204). 株式会社ドワンゴ. Kindle 版.
ユースケースには、リポジトリインターフェースを定義しており、ユースケースはそれに依存することで、データストア操作の「詳細」を知らずにいる(=依存しない)ことができました。つまり、ユースケースとリポジトリ(具体型)の間には境界線があります。

今回は、リポジトリとORMの間に境界線をひきます。これによって、リポジトリからすればORMの「詳細」(何を使っているか)を知る必要はありません。

それでは、
- インターフェースを定義する
- リポジトリを実装する
- インターフェースに合わせてsqlcの具体型を実装する
- リポジトリのテストを行う
といった順番で実装していきましょう。
インターフェースを定義する
まずインターフェースの名前を決めます。
ORMを抽象化するためのインターフェースだから、type Orm interface {..}としてもいいかもしれません。しかし。ORMはあくまでデータ操作をうまくやってくれるもので、自前で実装するパターンもありえます。それを踏まえて、「クエリを操作する役割」を担うコンポーネントの抽象型と認識できるようにQuerierという命名にしました。
app/adapter/repositoryディレクトリを用意し、以下のインターフェースを定義してください。
interface_querier.go の実装
// app/adapter/repository/interface_querier.go
package repository
import "context"
// ORMはこのインターフェースを満たすように設計する
type Querier interface {
DeleteTask(ctx context.Context, id string) error
DeleteUser(ctx context.Context, id string) error
FetchAllUser(ctx context.Context) ([]FetchAllUsersRow, error)
FindTaskById(ctx context.Context, id string) (FindTaskByIdRow, error)
FindUserByEmail(ctx context.Context, email string) (FindUserByEmailRow, error)
FindUserById(ctx context.Context, id string) (FindUserByIdRow, error)
InsertTask(ctx context.Context, arg InsertTaskParams) error
InsertUser(ctx context.Context, arg InsertUserParams) error
UpdateTask(ctx context.Context, arg UpdateTaskParams) error
UpdateUser(ctx context.Context, arg UpdateUserParams) error
}
type FetchAllUsersRow struct {
ID string
Email string
Name string
HashedPassword string
}
type FindUserByEmailRow struct {
ID string
Email string
Name string
HashedPassword string
}
type FindUserByIdRow struct {
ID string
Email string
Name string
HashedPassword string
}
type InsertUserParams struct {
ID string
Name string
Email string
HashedPassword string
}
type UpdateUserParams struct {
Name string
Email string
ID string
}
type FindTaskByIdRow struct {
ID string
UserID string
Content string
State int32
}
type InsertTaskParams struct {
ID string
UserID string
Content string
State int32
}
type UpdateTaskParams struct {
State int32
ID string
}
実は、このインターフェースはsqlcが生成したコードに寄った記述をしています。というのも、sqlcはSQLからタイプセーフなGoのコードを生成してくれるもの(後に触れます)で、生成されるコードを財産と捉えて(笑)借用しています。ただし、このインターフェースはsqlcに依存しているわけではありません。 gormでデータベース操作を行う場合は、このインターフェースに従った実装にすることで、gormに依存しない設計が可能となり、将来的に他のORMに切り替える際にも柔軟に対応できるようになります。
リポジトリの実装
定義したインターフェースを使用してリポジトリを実装していきましょう。Redisといったキーバリューストアを操作するリポジトリは第10章で扱います。usersテーブルを操作するユーザーリポジトリとtasksテーブルを操作するタスクリポジトリをそれぞれ実装していきましょう。
ユーザーリポジトリ
ドメイン層にはリポジトリインターフェースを定義していました。そのインターフェースを満たす形で実装します。
user_repository.go の実装
// app/adapter/repository/user_repository.go
package repository
import (
"context"
"database/sql"
"github.com/kakkky/app/domain/errors"
"github.com/kakkky/app/domain/user"
)
type userRepository struct {
querier Querier
}
func NewUserRepository(querier Querier) user.UserRepository {
return &userRepository{
querier: querier,
}
}
func (ur *userRepository) Save(ctx context.Context, user *user.User) error {
arg := InsertUserParams{
ID: user.GetID(),
Name: user.GetName(),
Email: user.GetEmail().Value(),
HashedPassword: user.GetHashedPassword().Value(),
}
if err := ur.querier.InsertUser(ctx, arg); err != nil {
return err
}
return nil
}
func (ur *userRepository) FindByEmail(ctx context.Context, email user.Email) (*user.User, error) {
u, err := ur.querier.FindUserByEmail(ctx, email.Value())
if err != nil && errors.Is(err, sql.ErrNoRows) {
return nil, errors.ErrNotFoundUser
}
if err != nil {
return nil, err
}
user := user.ReconstructUser(
u.ID,
u.Email,
u.Name,
u.HashedPassword,
)
return user, nil
}
func (ur *userRepository) FindById(ctx context.Context, id string) (*user.User, error) {
u, err := ur.querier.FindUserById(ctx, id)
if err != nil && errors.Is(err, sql.ErrNoRows) {
return nil, errors.ErrNotFoundUser
}
if err != nil {
return nil, err
}
user := user.ReconstructUser(
u.ID,
u.Email,
u.Name,
u.HashedPassword,
)
return user, nil
}
func (ur *userRepository) FetchAllUsers(ctx context.Context) (user.Users, error) {
us, err := ur.querier.FetchAllUser(ctx)
if err != nil {
return nil, err
}
// あらかじめ配列のキャパを確保しておく
users := make(user.Users, 0, len(us))
for _, u := range us {
user := user.ReconstructUser(
u.ID,
u.Email,
u.Name,
u.HashedPassword,
)
users = append(users, user)
}
return users, nil
}
func (ur *userRepository) Update(ctx context.Context, user *user.User) error {
params := UpdateUserParams{
Name: user.GetName(),
Email: user.GetEmail().Value(),
ID: user.GetID(),
}
if err := ur.querier.UpdateUser(ctx, params); err != nil {
return err
}
return nil
}
func (ur *userRepository) Delete(ctx context.Context, user *user.User) error {
if err := ur.querier.DeleteUser(ctx, user.GetID()); err != nil {
return err
}
return nil
}
userRepositoryには、インターフェースQuerierを持たせています。実装の詳細としてSqlcQuerierを第11章にてDI(依存性の注入)します。
ポイントは、errors.Is(err, sql.ErrNoRows)としているところです。
if err != nil && errors.Is(err, sql.ErrNoRows) {
return nil, errors.ErrNotFoundUser
}
if err != nil {
return nil, err
}
標準であるsqlパッケージには、ErrNoRowsが定義されています。
// ErrNoRows is returned by [Row.Scan] when [DB.QueryRow] doesn't return a
// row. In such a case, QueryRow returns a placeholder [*Row] value that
// defers this error until a Scan.
var ErrNoRows = errors.New("sql: no rows in result set")
入力値としてemailを入力したとして、該当するユーザーが見つからなければそれは「入力値エラー」であり、クライアント側の問題であるドメインエラーと見做せます。そのため、errors.Isでエラーの値を判別し、それがErrNoRows(=該当のレコードがない)であればドメインエラー(errors.ErrNotFoundUser)を返し、それ以外のエラーならそのままerrを返すようにしています。このように、ドメインエラーをハンドリングできるようにしています。
タスクリポジトリ
タスクリポジトリも、ユーザーリポジトリと同様、ドメイン層に定義したインターフェースを満たすように実装します。
task_repository.go の実装
// app/adapter/repository/task_repository.go
package repository
import (
"context"
"database/sql"
"github.com/kakkky/app/domain/errors"
"github.com/kakkky/app/domain/task"
)
type taskRepository struct {
querier Querier
}
func NewTaskRepository(querier Querier) task.TaskRepository {
return &taskRepository{
querier: querier,
}
}
func (tr *taskRepository) FindById(ctx context.Context, id string) (*task.Task, error) {
t, err := tr.querier.FindTaskById(ctx, id)
if err != nil && errors.Is(err, sql.ErrNoRows) {
return nil, errors.ErrNotFoundTask
}
if err != nil {
return nil, err
}
task := task.ReconstructTask(
t.ID,
t.UserID,
t.Content,
int(t.State),
)
return task, nil
}
func (tr *taskRepository) Save(ctx context.Context, task *task.Task) error {
arg := InsertTaskParams{
ID: task.GetID(),
UserID: task.GetUserId(),
Content: task.GetContent().Value(),
State: int32(task.GetState().IntValue()),
}
if err := tr.querier.InsertTask(ctx, arg); err != nil {
return err
}
return nil
}
func (tr *taskRepository) Update(ctx context.Context, task *task.Task) error {
arg := UpdateTaskParams{
ID: task.GetID(),
State: int32(task.GetState().IntValue()),
}
if err := tr.querier.UpdateTask(ctx, arg); err != nil {
return err
}
return nil
}
func (tr *taskRepository) Delete(ctx context.Context, task *task.Task) error {
if err := tr.querier.DeleteTask(ctx, task.GetID()); err != nil {
return err
}
return nil
}
このように、特にsqlcを導入したわけではありませんが、リポジトリを実装することができました。これも、リポジトリとORMの間にインターフェースによる「境界線」を引いているために実現できています。
sqlcの設定
ormとして、sqlcを導入します。
sqlcとは
以下が公式ドキュメントになります。
他のORMと圧倒的に異なる点であり最大の特徴は、
SQLからタイプセーフなGoコードを生成する
ことです。 sqlcを使うと、以下のメリットをもたらします。
- 実行されているSQLクエリがわかる
- 意図せぬN+1を生みにくい
実行されているSQLクエリがわかる
SQLを直接記述し、それを元にgoコードが生成されます。そのため、ORMに隠蔽されたクエリの動作を機にする必要がありません。実際に実行されるSQLクエリを正確に把握できるのは非常に開発がしやすいです。
意図せぬN+1を生みにくい
gormなどの便利なORMは、GoコードからSQLクエリを生成してくれるため非常に強力ですが、SQL操作が抽象化されている分、N+1問題を発生させるコードを書きやすい側面もあると考えています。
たとえば、以下の記事で紹介されているように Eager Loading を利用すればN+1問題を回避することも可能ですが、設定を間違えると問題が発生するリスクがあります
そもそもSQLを直接書くなら、テーブルをJOINしたクエリを書いて一発で取得できるようにするのが普通?の感覚だと思うので、余計な心配がいらないと筆者は思っています。
言語が異なりますが、筆者はよくrailsで「あっ...N+1キタ」となった経験がよくあります。Active Recordも超便利なので、便利なだけに、中途半端な理解で扱ってしまうとパフォーマンス的にアレな結果になります。
(こう思っているのは筆者だけでしょうか...笑)
このように、SQLクエリ中心の開発にはメリットがあります。
N+1問題についてよくわからない方は以下の記事をご覧ください。
セットアップ
設定ファイルを記述
sqlcについては、すでにコンテナ内にインストールされています。
RUN go install github.com/sqlc-dev/sqlc/cmd/[email protected]
基本的には公式ドキュメントを参照しています。app/infrastructure/db/sqlcディレクトリを作成してください。
まず、設定ファイルを作成し、記述していきます。
# app/infrastructure/db/sqlc/sqlc.yml
version: "2"
sql:
- engine: "mysql"
queries: "./queries/"
schema: "../migrations/"
gen:
go:
package: "sqlc"
out: "./"
emit_json_tags: false #構造体にjsonタグをつけない
emit_prepared_queries: false #プリペアードステートメントは使用しない
emit_interface: false #インターフェースを生成しない
emit_exact_table_names: false #テーブル名(s抜き)がそのまま構造体としない
emit_empty_slices: true #:manyクエリでnilの代わりに空スライスが返される
queries: "./queries/"で指定したパスは、「SQLクエリを記述しているファイル」が配置されているディレクトリを指します。これが生成されるgoコードの元となります。
また、schema: "../migrations/"としている点に注目ください。sqlcは、コードを生成するためにデータベースのスキーマを認識する必要があります。
データベースのスキーマ変更などは、golang-migrateで行っていました。
超便利なことに、sqlcはgolang-migrateに対応してくれています。
生成したマイグレーションファイル群からスキーマを認識してくれるのです。そのため、schema: "../migrations/"として、「スキーマはマイグレーションファイルを見てください」としています。
SQLを記述
app/infrastructure/db/sqlc/queriesディレクトリにsqlファイルを配置し、記述します。
sqlの実装
usersテーブルの操作
-- app/infrastructure/db/sqlc/queries/users.sql
-- name: FindUserByEmail :one
select id,email,name,hashed_password
from users
where email = sqlc.arg(email);
-- name: FindUserById :one
select id,email,name,hashed_password
from users
where id = sqlc.arg(id);
-- name: FetchAllUsers :many
select id,email,name,hashed_password
from users;
-- name: InsertUser :exec
insert into users (
id,
name,
email,
hashed_password
) values (
sqlc.arg(id),
sqlc.arg(name),
sqlc.arg(email),
sqlc.arg(hashed_password)
);
-- name: UpdateUser :exec
update users
set name=sqlc.arg(name),email=sqlc.arg(email)
where id=sqlc.arg(id);
-- name: DeleteUser :exec
delete from users
where id=sqlc.arg(id);
tasksテーブルの操作
-- app/infrastructure/db/sqlc/queries/tasks.sql
-- name: FindTaskById :one
select id,user_id,content,state
from tasks
where id = sqlc.arg(id);
-- name: FetchTaskById :one
select t.id,u.name,t.user_id,t.content,t.state
from tasks as t inner join users as u
on t.user_id = u.id
where t.id = sqlc.arg(id);
-- name: FetchUserTasks :many
select t.id,u.name,t.user_id,t.content,t.state
from tasks as t inner join users as u
on t.user_id = u.id
where t.user_id = sqlc.arg(user_id);
-- name: FetchAllTasks :many
select t.id,u.name,t.user_id,t.content,t.state
from tasks as t inner join users as u
on t.user_id = u.id;
-- name: InsertTask :exec
insert into tasks(
id,
user_id,
content,
state
)values(
sqlc.arg(id),
sqlc.arg(user_id),
sqlc.arg(content),
sqlc.arg(state)
);
-- name: UpdateTask :exec
update tasks
set state=sqlc.arg(state)
where id = sqlc.arg(id);
-- name: DeleteTask :exec
delete from tasks
where id=sqlc.arg(id);
--name : 生成するメソッド名 :クエリ注釈(種類)を各クエリの上につけます。
クエリ注釈(Query annotations)は、:execか:manyか:oneしか扱っていません。簡単には、以下の用途で使い分けます。
| クエリ注釈 | 説明 |
|---|---|
:exec |
クエリ実行のみ (レコードは返さない) |
:many |
複数レコードを取得 |
:one |
単一レコードを取得 |
コード生成
sqlc generateコマンドでコードは生成されます。
Makefileにはタスクを定義しています。
# sqlcでコードを生成
sqlc-gen:
@echo "Generating query in sql by sqlc..."
cd ./app/infrastructure/db/sqlc && sqlc generate
コマンドを打って、コードを生成してください。
make sqlc-gen
すると、/sqlcディレクトリ配下にいくつかファイルが生成されたと思います。
※ 生成されたファイル群のみ示しています。
├── db.go // 生成されたメソッドを使うためのコード(DBとの連携やトランザクション)
├── models.go // テーブルに対応する構造体
├── tasks.sql.go // `tasks` テーブル関連のSQLクエリと対応するコード
└── users.sql.go // `users` テーブル関連のSQLクエリと対応するコード
適宜、ファイルの中身をのぞいてみてください。users.sql.goを少し取り上げてみます。ユーザーを削除するためのメソッドが以下のように定義されているはずです。
func (q *Queries) DeleteUser(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteUser, id)
return err
}
レシーバに*Queriesとあるのに注目してください。sqlcによって生成されるメソッド群は、この*Queriesを介して扱うことができます。
db.goを見てみましょう。
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
DBTXインターフェースは、sql.DB(データベース接続)とsql.Tx(トランザクション)を抽象化して扱うためのものです。今回は、New()関数の引数に*sql.DBを入れて*Queriesを扱えるようにします。具体的なやりかたは次のセクションで述べます。
SqlcQuerier(Querier具体型)の実装
定義したインターフェースQuerierを満たすように、Querierの具体型としてSqlcQuerierを実装します。
sqlc_querier.go の実装
// app/infrastructure/db/sqlc/sqlc_querier.go
package sqlc
import (
"context"
"github.com/kakkky/app/adapter/repository"
"github.com/kakkky/app/infrastructure/db"
)
// Querierインターフェースを満たす
// sqlcはここから利用する
type SqlcQuerier struct {
queries *Queries
}
func NewSqlcQuerier() *SqlcQuerier {
return &SqlcQuerier{
queries: New(db.GetDB()),
}
}
func (sq *SqlcQuerier) DeleteTask(ctx context.Context, id string) error {
return sq.queries.DeleteTask(ctx, id)
}
func (sq *SqlcQuerier) DeleteUser(ctx context.Context, id string) error {
return sq.queries.DeleteUser(ctx, id)
}
func (sq *SqlcQuerier) FetchAllUser(ctx context.Context) ([]repository.FetchAllUsersRow, error) {
rows, err := sq.queries.FetchAllUsers(ctx)
var r []repository.FetchAllUsersRow
for _, row := range rows {
r = append(r, repository.FetchAllUsersRow(row))
}
return r, err
}
func (sq *SqlcQuerier) FindTaskById(ctx context.Context, id string) (repository.FindTaskByIdRow, error) {
row, err := sq.queries.FindTaskById(ctx, id)
return repository.FindTaskByIdRow(row), err
}
func (sq *SqlcQuerier) FindUserByEmail(ctx context.Context, email string) (repository.FindUserByEmailRow, error) {
row, err := sq.queries.FindUserByEmail(ctx, email)
return repository.FindUserByEmailRow(row), err
}
func (sq *SqlcQuerier) FindUserById(ctx context.Context, id string) (repository.FindUserByIdRow, error) {
row, err := sq.queries.FindUserById(ctx, id)
return repository.FindUserByIdRow(row), err
}
func (sq *SqlcQuerier) InsertTask(ctx context.Context, arg repository.InsertTaskParams) error {
return sq.queries.InsertTask(ctx, InsertTaskParams(arg))
}
func (sq *SqlcQuerier) InsertUser(ctx context.Context, arg repository.InsertUserParams) error {
return sq.queries.InsertUser(ctx, InsertUserParams(arg))
}
func (sq *SqlcQuerier) UpdateTask(ctx context.Context, arg repository.UpdateTaskParams) error {
return sq.queries.UpdateTask(ctx, UpdateTaskParams(arg))
}
func (sq *SqlcQuerier) UpdateUser(ctx context.Context, arg repository.UpdateUserParams) error {
return sq.queries.UpdateUser(ctx, UpdateUserParams(arg))
}
SqlcQuerier構造体は*Queriesをラップしています。
// Querierインターフェースを満たす
// sqlcはここから利用する
type SqlcQuerier struct {
queries *Queries
}
func NewSqlcQuerier() *SqlcQuerier {
return &SqlcQuerier{
queries: New(db.GetDB()),
}
}
また、sqlcによって生成されたファイルであるdb.goに定義されたNew()関数を使って*Queriesを取得しています。New()関数の引数には、引数にはパッケージ変数にセットした*sql.DBインスタンスを取得して入れています(`db.GetDB())。
*SqlcQuerierに定義したメソッドを一つ取り上げます。これもまた、sqlcによって用意されたメソッドをラップした形になっていることがわかるでしょうか。repository.InsertTaskParams型を受け取り、sqlcのInsertTaskParams型に変換した上で、*Queries構造体に定義されているInsertTaskメソッドを呼び出すことです。
func (sq *SqlcQuerier) InsertTask(ctx context.Context, arg repository.InsertTaskParams) error {
// 外部から受け取った repository.InsertTaskParams 型の引数 arg を、
// sqlc パッケージの InsertTaskParams 型にキャスト
convertedArg := InsertTaskParams(arg)
// 内部の sq.queries.InsertTask メソッドを呼び出して処理を実行
return sq.queries.InsertTask(ctx, convertedArg)
}
若干ややこしいことをしていますが、定義ジャンプなどしたりして整理してみてください。
リポジトリのテスト
リポジトリのテストは、テストDB環境で行います。書籍では、以下のように言及されています。
引数やオブジェクトの詰め替え等のリポジトリー特有の処理や、複数テーブルにまたがる永続化処理の整合性を保つことができます。
上田 裕耀; 菅間 伶史; ギュウ. Go言語で構築するクリーンアーキテクチャ設計 技術の泉シリーズ (p.87). インプレス NextPublishing. Kindle 版.
ORMやデータベースのバージョンが変更されたり、データベースが差し替えられたりした場合にも、リポジトリーの挙動を保証します。
上田 裕耀; 菅間 伶史; ギュウ. Go言語で構築するクリーンアーキテクチャ設計 技術の泉シリーズ (p.88). インプレス NextPublishing. Kindle 版.
「リポジトリー」は一つのコンポーネントですが、役割は「データストアの操作」です。そこには、ORMやRDBといった、インフラストラクチャ層の要素も関わってきます。要は、リポジトリーの挙動を正確に検証するなら、モック化したりするよりもテストDB環境を用意してテストしよう!という考えです。
※ 矢印は依存の方向ではなく処理の流れです。
ということで、テストDB環境を立てる必要が出てきました。以下の流れで実装していきます。
- dockertestによるDBコンテナの用意
- コンテナ内DBに接続する
- TestMainを記述
- テストフィクスチャの用意
- 各テストを記述
細かい話は抜きにしますが、簡単に説明します。
開発環境では、DBサーバーをコンテナ内で起動していました。テスト環境でも同じことをする、いわばそれだけです!
テスト中だけ起動するコンテナの中のDBサーバーに接続し、テストが終わったらコンテナは削除されます。
dockertestによるDBコンテナの用意
まずはDBサーバーを起動させるコンテナを用意するところからやっていきます。これにはとても便利なパッケージがありまして、dockertestというものがあります。
以下のコマンドでインストールしてください。
make get-app name="github.com/ory/dockertest/v3"
app/infrastructure/db/containerディレクトリを作成してください。
dockertest.go の実装
// app/infrastructure/db/container/dockertest.go
package container
import (
"log"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
)
var (
userName = "root"
password = "secret"
hostname = "localhost"
dbName = "test-db"
port int //ポート番号は起動したコンテナから取得する(ランダム)
)
// DockerTestのコンテナを起動
func NewDockertestContainer() (*dockertest.Pool, *dockertest.Resource) {
// デフォルトでUnixソケットを使用する
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("failled to construct pool:%v", err)
}
// Dockerに接続確認
if err := pool.Client.Ping(); err != nil {
log.Fatalf("failed to connect to Docker: %v", err)
}
// コンテナの起動設定を指定
runOptions := &dockertest.RunOptions{
Repository: "mysql",
Tag: "8",
Env: []string{
"MYSQL_ROOT_PASSWORD=" + password,
"MYSQL_DATABASE=" + dbName,
},
// 開発環境の設定に揃える
Cmd: []string{
"mysqld",
"--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci",
},
}
resource, err := pool.RunWithOptions(runOptions, func(hc *docker.HostConfig) {
// コンテナは停止後に自動削除される
hc.AutoRemove = true
// コンテナが異常終了しても再起動しない
hc.RestartPolicy = docker.RestartPolicy{
Name: "no",
}
})
if err != nil {
log.Fatalf("failed to start resource: %v", err)
}
return pool, resource
}
// コンテナを削除する
func RemoveDockertestContainer(pool *dockertest.Pool, resource *dockertest.Resource) {
if err := pool.Purge(resource); err != nil {
log.Fatalf("failed to purge resource: %s", err)
}
}
コンテナを起動する関数と削除する関数を用意しています。それぞれ見ていきましょう。
NewDockertestContainer()
基本的には以下の例を参考にしています。
コンテナを起動するにあたってオプションを指定しています。開発環境とほぼ同じものを再現するためです。
// コンテナの起動設定を指定
runOptions := &dockertest.RunOptions{
Repository: "mysql",
Tag: "8",
Env: []string{
"MYSQL_ROOT_PASSWORD=" + password,
"MYSQL_DATABASE=" + dbName,
},
// 開発環境の設定に揃える
Cmd: []string{
"mysqld",
"--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci",
},
}
次の部分で実際にコンテナを起動しています。
resource, err := pool.RunWithOptions(runOptions, func(hc *docker.HostConfig) {
// コンテナは停止後に自動削除される
hc.AutoRemove = true
// コンテナが異常終了しても再起動しない
hc.RestartPolicy = docker.RestartPolicy{
Name: "no",
}
})
そして、関数は返り値として*dockertest.Poolと*dockertest.Resourceを返しています。
*dockertest.Pool
// Pool represents a connection to the docker API and is used to create and remove docker images.
type Pool struct {
Client *dc.Client
MaxWait time.Duration
}
*dockertest.Poolは、dockerコンテナとの接続情報を管理する構造体のようです。
Dockerイメージやコンテナの作成・削除を行うメソッドなどが定義されています。コンテナを操作するには*dockertest.Poolを介する必要がありそうです。RemoveDockertestContainer()にてコンテナを削除したいため、*dockertest.Poolを返しています。コンテナの削除にも``*dockertest.Pool`が必要だからです。
コネクションプール
コネクションプールとは、データベースや外部サービスへの接続をあらかじめ複数確保して再利用する仕組みで、接続の効率化と高速化を図るためのものです。
*dockertest.Resource
// Resource represents a docker container.
type Resource struct {
pool *Pool
Container *dc.Container
}
Dockerコンテナのインスタンスを表しているようです。GetPort()など、起動しているコンテナの情報にアクセスするためのメソッドが定義されています。起動されるコンテナの公開ポート番号(ホスト側ポート番号)はランダムです。DB接続の際にはポート番号が必要なので、GetPort()でDockerコンテナインスタンスからポート番号を取得したりします。
RemoveDockertestContainer()
pool.Purge(resource)の部分が、*Poolでコンテナ操作(削除)をしていて、引数には削除したいコンテナのインスタンスとして*Resourceがある、ということがわかればOKです。
コンテナ内DBに接続する
NewDockertestContainer()で返された起動したコンテナの情報*dockertest.Poolと*dockertest.Resourceを使用して、コンテナ内のMySQLサーバーに接続しましょう。
db.go の実装
// app/infrastructure/db/container/db.go
package container
import (
"database/sql"
"fmt"
"log"
"path/filepath"
"runtime"
"strconv"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/mysql"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/kakkky/app/domain/errors"
"github.com/ory/dockertest/v3"
)
// DBに接続する
func NewDB(pool *dockertest.Pool, resource *dockertest.Resource) *sql.DB {
var db *sql.DB
// エラーだと再実行を繰り返す
if err := pool.Retry(func() error {
var err error
// 公開ポート番号を取得
port, err = strconv.Atoi(resource.GetPort("3306/tcp")) //コンテナ内のポート番号は3306
if err != nil {
return err
}
// DBに接続
db, err = sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true", userName, password, hostname, port, dbName))
if err != nil {
return err
}
// 接続確認
return db.Ping()
}); err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
return db
}
func SetupDB() {
migrationsPath := getMigrationsPath()
m, err := migrate.New(migrationsPath, fmt.Sprintf("mysql://%s:%s@tcp(%s:%d)/%s?parseTime=true", userName, password, hostname, port, dbName))
if err != nil {
log.Fatalf("failed to create migrate instance:%v", err)
}
// マイグレーションをテストDBに適用させる
if err := m.Up(); err != nil {
if errors.Is(err, migrate.ErrNoChange) {
log.Println("migrations have already been applied")
} else {
log.Fatalf("failed to up migrations:%v", err)
}
}
}
const migrationsRelativePath = "../migrations"
// どこからSetupDBを呼び出しても/migrationsへのパスを取得できるようにする
func getMigrationsPath() string {
// Callerを呼んだファイル名(ファイルパス)を取得する
_, callerFile, _, ok := runtime.Caller(0)
print(callerFile)
if !ok {
log.Fatal("failed to get caller directory")
}
// ファイルパスからディレクトリ部分を取り出す
callerDir := filepath.Dir(callerFile)
// /migrationsへの絶対パスを作成
migrationsAbsPath := "file://" + filepath.Join(callerDir, migrationsRelativePath)
return migrationsAbsPath
}
NewDB()
NewDB()関数を定義しています。こ開発環境におけるDBサーバーへの接続関数(db.NewDB())と同じ役割であることをわかりやすくするために同名にしています。
*docker.PoolからRetry()を使っています。これはエクスポネンシャルバックオフを行ってくれようです。引数に関数を入れていて、その中で接続を行っているのがわかるかと思います。
開発環境での処理とそう大差ないのですが、ポート番号だけ若干異なるかなと思います。
dockertestにより立ち上がるコンテナはランダムです(オプションで設定することもできそうです)。空いているポート番号を勝手に割り当ててくれます。そのため、立ち上がったコンテナのインスタンス(*docketest.Resource)からポート番号を取得しています。
SetupDB()
テスト用DBにマイグレーションを適用させる処理を記述しています。前章では、CLIとして使用したgolang-migrateですが、今回はパッケージとして使用します。パッケージをインストールしてください。
make get-app name="github.com/golang-migrate/migrate/v4"
migrate.New()でマイグレーションを行える*migrate.Migrateインスタンスmを取得し、m.Up()でマイグレーションをアップするというわかりやすい流れになっています。
例はポスグレですが👇
getMigrationsPath()
ただ、若干ややこしいのは内部で呼び出しているプライベート関数getMigrationsPath()です。migrate.New()の第一引数にはスキーマを読み込ませる必要があります。
すでに作成したapp/infrastructure/db/migrationsディレクトリへのパスを取得する処理を記述しています。
runtimeパッケージを使って、コールスタックにアクセスしている点がポイントです。
const migrationsRelativePath = "../migrations"
// どこからSetupDBを呼び出しても/migrationsへのパスを取得できるようにする
func getMigrationsPath() string {
// Caller()を呼んだ関数のあるファイル名(ファイルパス)を取得する
_, callerFile, _, ok := runtime.Caller(0)
if !ok {
log.Fatal("failed to get caller directory")
}
// ファイルパスからディレクトリ部分を取り出す
callerDir := filepath.Dir(callerFile)
// /migrationsへの絶対パスを作成
migrationsAbsPath := "file://" + filepath.Join(callerDir, migrationsRelativePath)
return migrationsAbsPath
}
Caller()を呼び出した関数があるファイルパスを取得- ファイルパスからディレクトリ部分を取り出す(つまり
/containerディレクトリ) file://スキームをつけ、/migrationsへの絶対パスを作成
と、ざっくりはこんな感じの流れです。
コールスタック
呼び出された(コール)関数から積み上げられて(スタック)いきます。
runtime.Caller()シグネチャは以下のようになっており、引数にとる数字で、コールスタックをいくつ遡るかを指定できます。
func runtime.Caller(skip int) (pc uintptr, file string, line int, ok bool)
runtime.Caller(0)は、Caller()が呼ばれた関数、つまりgetMigrationsPath()関数をさしています。
これにより、どこから呼び出してもgetMigrationsPath()関数のあるapp/infrastructure/containerディレクトリから../migrationsを検索することが可能になっています。
(getMigrationsPath()を呼び出している)SetupDB()は、今後様々なディレクトリで使用していくので、このようにしておくと便利です。
../migrationsとするだけではダメなのか
getMigrationsPath()なんてややこしい処理はやめて、migrate.New()に../migrationsと相対パスを入れれたら単純でラクです。
これじゃダメなんでしょうか。
結論、うまくパスを見つけてくれません。というのも、相対パスを提示するだけでは、ワーキングディレクトリ(テストを実行しているディレクトリ等)を基点として相対パスを辿ってしまうからです。伝えにくいので、適宜書き換えて試してみていただけると理解いただけるのかなと思います。
そのため、コールスタックからアプローチする方法を取りましたが、もっといい方法もあるのかもしれません。
TestMainを記述
テストDBコンテナの起動、またDBへの接続処理を実装したので、これを呼び出します。
現状、ユーザーリポジトリとタスクリポジトリのテストを行う予定ですが、「コンテナ起動→DB接続」の処理はこの2つのテストで共通して行いたいことです。その場合、TestMainに記述すると、同パッケージにおけるテストケースに共通処理を走らせることができます。
以下のような形で記述することができます。
func TestMain(m *testing.M) {
// 前処理
m.Run() // 各テストケースの実行
// 後処理
}
イメージとしては、以下のようにテストを実行できるようになります。

それではリポジトリテストの共通処理を記述していきましょう。app/infrastructure/db/repository_test/ディレクトリを作成してください。
repository_test.go の実装
// app/infrastructure/db/repository_test/repository_test.go
package repository_test
import (
"log"
"testing"
"github.com/kakkky/app/infrastructure/db"
"github.com/kakkky/app/infrastructure/db/container"
)
func TestMain(m *testing.M) {
//dockertestコンテナを起動
pool, resource := container.NewDockertestContainer()
log.Println("success to start dockertest container")
defer func() {
container.RemoveDockertestContainer(pool, resource)
log.Println("success to remove dockertest container")
}()
// DBに接続
testDB := container.NewDB(pool, resource)
log.Println("success to connect test-db")
defer testDB.Close()
// マイグレーションを適用させる
container.SetupDB()
log.Println("success to apply migrations")
// dbパッケージ変数にテスト用DBをセット
db.SetDB(testDB)
log.Println("dockertest & test-db settings complete")
// (第10章ではRedisのテスト用クライアントの処理を呼び出します)
m.Run()
}
テストフィクスチャの用意
テストフィクスチャとは
テストフィクスチャ(Test Fixture) とは、テストを実行するために必要な初期設定やデータのことです。テストが期待通りに実行される環境や条件を整えるために使用します。
これも便利なパッケージがありまして、testfixturesというものを使っていきます。
ドキュメントにある通り、Railsのフィクスチャ形式を真似ているようです。 ymlファイルに記述するのですが、とてもシンプルで筆者的にとても扱いやすいです。
Basically this package mimics the "Ruby on Rails' way" of writing tests for database applications, where sample data is kept in fixtures files. Before the execution of every test, the test database is cleaned and the fixture data is loaded into the database.
訳) 基本的に、このパッケージはデータベースアプリケーションのテストを書く「Ruby on Railsの方法」を模倣しており、サンプルデータはフィクスチャファイルに保存されます。
以下のコマンドでインストールしてください。
make get-app name="github.com/go-testfixtures/testfixtures/v3"
実装
app/infrastructure/db/testhelperディレクトリを作成してください。
// app/infrastructure/db/testhelper/fixtures.go
func SetupFixtures(t *testing.T, fixture_path ...string) {
t.Helper()
fixtures, err := testfixtures.New(
testfixtures.Database(db.GetDB()),
testfixtures.Dialect("mysql"),
testfixtures.SkipResetSequences(),
testfixtures.Files(
fixture_path...,
),
)
if err != nil {
t.Fatalf("testfixtures failed to create Loader instance:%v", err)
}
// テーブルのデータを削除&用意
if err := fixtures.Load(); err != nil {
t.Fatalf("testfixtures failed to load fixtures:%v", err)
}
}
特にややこしい点もないかなと思います。
引数は(fixture_path ...string) というふうに、可変長引数となっています。タスクのデータを用意するなら、付随して所有者であるユーザーのデータも必要ですよね。幾つでもフィクスチャファイルを受け付けるようにするためにこうしています。
また、testfixtures.Load()は、以下の通り、ロードする前に一旦データベースのデータを一掃してくれるようです。
// Load wipes and after load all fixtures in the database.
//
// if err := fixtures.Load(); err != nil {
// ...
// }
func (l *Loader) Load() error {
// 省略
}
テストケースごとにこの処理を差し込めば、テストケースごとにデータベースの状況を操作してテストできそうですね。
フィクスチャファイルも用意しておきます。app/infrastructure/repository_test/testdata/fixturesディレクトリを作成してください。
# users.yml
- id: 1
name: user1
email: [email protected]
hashed_password: password
- id: 2
name: user2
email: [email protected]
hashed_password: password
- id: 3
name: user3
email: [email protected]
hashed_password: password
# tasks.yml
- id: 1
user_id: 1
content: content
state: 0
テストを記述
それぞれテストを記述していきます。
ユーザーリポジトリのテスト
user_repository_test.go の実装
// app/infrastructure/repository_test/user_repository_test.go
package repository_test
import (
"context"
"fmt"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/kakkky/app/adapter/repository"
"github.com/kakkky/app/domain/errors"
"github.com/kakkky/app/domain/user"
"github.com/kakkky/app/infrastructure/db/sqlc"
testhelper "github.com/kakkky/app/infrastructure/db/testhelper"
)
func TestUserRepository_Save(t *testing.T) {
userRepository := repository.NewUserRepository(sqlc.NewSqlcQuerier())
// ユーザーインスタンスを用意
user0, _ := user.NewUser(
"[email protected]",
"user0",
"password",
)
type args struct {
user *user.User
email user.Email
}
// テストテーブル
tests := []struct {
name string
args args
want *user.User
wantErr bool
}{
{
name: "正常系: 1人ユーザーが追加され、emailで検索できる",
args: args{
user: user0,
email: user0.GetEmail(),
},
want: user0,
wantErr: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
// ユーザーを保存
if err := userRepository.Save(ctx, tt.args.user); (err != nil) != tt.wantErr {
t.Errorf("userRepository.Save()=error %v ,but wantErr:%v", err, tt.wantErr)
}
// ユーザーをemailで検索
got, err := userRepository.FindByEmail(ctx, tt.args.email)
// 返すエラーはErrNotFoundUserのみであるべき
if (err != nil) != tt.wantErr {
t.Errorf("userRepository.FindByEmail() = error %v,but wantErr :%v", err, tt.wantErr)
}
if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(user.User{}, user.Email{}, user.HashedPassword{}), cmpopts.IgnoreFields(user.User{}, "id", "hashedPassword")); diff != "" {
t.Errorf("userRepository.FindByEmail() -got,+want :%v ", diff)
}
})
}
}
func TestUserRepository_FindByEmail(t *testing.T) {
userRepository := repository.NewUserRepository(sqlc.NewSqlcQuerier())
user1, _ := user.NewUser(
"[email protected]",
"user1",
"password",
)
noExistentUser, _ := user.NewUser(
"[email protected]",
"noexistent",
"password",
)
type args struct {
email user.Email
}
tests := []struct {
name string
args args
want *user.User
errType error
wantErr bool
}{
{
name: "正常系:ユーザーを検索できる",
args: args{
email: user1.GetEmail(),
},
want: user1,
wantErr: false,
},
{
name: "準正常系:ユーザーが見つからなければErrNotFoundUserが返ってくる",
args: args{
// 存在しないuser2のemailで検索
email: noExistentUser.GetEmail(),
},
errType: errors.ErrNotFoundUser,
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
// user1のみがDBに保存されている
testhelper.SetupFixtures(t,"testdata/fixtures/users.yml")
ctx := context.Background()
got, err := userRepository.FindByEmail(ctx, tt.args.email)
// 期待されるエラータイプが設定されている場合はそれも検証
if (err != nil) != tt.wantErr && tt.errType != nil {
t.Errorf("userRepository.FindByEmail() =error:%v, want errType:%v", err, tt.errType)
return
}
if (err != nil) != tt.wantErr {
t.Errorf("userRepository.FindByEmail() =error:%v, wantErr:%v", err, tt.wantErr)
}
if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(user.User{}, user.Email{}, user.HashedPassword{}), cmpopts.IgnoreFields(user.User{}, "id", "hashedPassword")); diff != "" {
t.Errorf("userRepository.FindByEmail() -got,+want :%v ", diff)
}
})
}
}
func TestUserRepository_FindById(t *testing.T) {
userRepository := repository.NewUserRepository(sqlc.NewSqlcQuerier())
user1 := user.ReconstructUser(
"1",
"[email protected]",
"user1",
"password",
)
noExistentUser := user.ReconstructUser(
"noExistent",
"",
"",
"",
)
type args struct {
id string
}
tests := []struct {
name string
args args
want *user.User
errType error
wantErr bool
}{
{
name: "正常系:ユーザーを検索できる",
args: args{
id: user1.GetID(),
},
want: user1,
wantErr: false,
},
{
name: "準正常系:ユーザーが見つからなければErrNotFoundUserが返ってくる",
args: args{
id: noExistentUser.GetID(),
},
errType: errors.ErrNotFoundUser,
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
// user1のみがDBに保存されている
testhelper.SetupFixtures(t,"testdata/fixtures/users.yml")
ctx := context.Background()
got, err := userRepository.FindById(ctx, tt.args.id)
// 期待されるエラータイプが設定されている場合はそれも検証
if (err != nil) != tt.wantErr && tt.errType != nil && errors.Is(err, tt.errType) {
t.Errorf("userRepository.FindById() =error:%v, want errType:%v", err, tt.errType)
return
}
if (err != nil) != tt.wantErr {
t.Errorf("userRepository.FindById() =error:%v, wantErr:%v", err, tt.wantErr)
}
if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(user.User{}, user.Email{}, user.HashedPassword{}), cmpopts.IgnoreFields(user.User{}, "id", "hashedPassword")); diff != "" {
t.Errorf("userRepository.FindById() -got,+want :%v ", diff)
}
})
}
}
func TestUserRepository_FetchAllUsers(t *testing.T) {
userRepository := repository.NewUserRepository(sqlc.NewSqlcQuerier())
var users user.Users
for i := 0; i < 3; i++ {
u, _ := user.NewUser(
fmt.Sprintf("user%[email protected]", i+1),
fmt.Sprintf("user%d", i+1),
"password",
)
users = append(users, u)
}
tests := []struct {
name string
want user.Users
wantErr bool
}{
{
name: "正常系:全てのユーザーを取得できる",
want: users,
wantErr: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
testhelper.SetupFixtures(t,"testdata/fixtures/users.yml")
ctx := context.Background()
got, err := userRepository.FetchAllUsers(ctx)
if (err != nil) != tt.wantErr {
t.Errorf("userRepository.FetchAllUsers() =error:%v, wantErr:%v", err, tt.wantErr)
}
if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(user.User{}, user.Email{}, user.HashedPassword{}), cmpopts.IgnoreFields(user.User{}, "id", "hashedPassword")); diff != "" {
t.Errorf("userRepository.FetchAllUsers() -got,+want :%v ", diff)
}
})
}
}
func TestUserRepository_Update(t *testing.T) {
userRepository := repository.NewUserRepository(sqlc.NewSqlcQuerier())
// 更新情報を詰め替えて再構成したユーザー
updatingUser := user.ReconstructUser(
"1",
"[email protected]", // 更新
"updatedUser", // 更新
"password",
)
updatingNameUser := user.ReconstructUser(
"1",
"[email protected]",
"nameUpdatedUser", //更新
"password",
)
type args struct {
user *user.User
}
tests := []struct {
name string
args args
want *user.User
wantErr bool
}{
{
name: "正常系: emailとnameの両方を更新する",
args: args{
user: updatingUser,
},
want: updatingUser,
wantErr: false,
},
{
name: "正常系: nameのみを更新する",
args: args{
user: updatingNameUser,
},
want: updatingNameUser,
wantErr: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
testhelper.SetupFixtures(t,"testdata/fixtures/users.yml")
ctx := context.Background()
if err := userRepository.Update(ctx, tt.args.user); (err != nil) != tt.wantErr {
t.Errorf("userRepository.Update()=error:%v, wantErr:%v", err, tt.wantErr)
}
got, err := userRepository.FindByEmail(ctx, tt.args.user.GetEmail())
if (err != nil) != tt.wantErr {
t.Errorf("userRepository.FindByEmail()=error:%v, wantErr:%v", err, tt.wantErr)
}
if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(user.User{}, user.Email{}, user.HashedPassword{}), cmpopts.IgnoreFields(user.User{}, "id", "hashedPassword")); diff != "" {
t.Errorf("userRepository.FindByEmail() -got,+want :%v ", diff)
}
})
}
}
func TestUserRepository_Delete(t *testing.T) {
userRepository := repository.NewUserRepository(sqlc.NewSqlcQuerier())
deletingUser := user.ReconstructUser(
"1",
"[email protected]",
"user1",
"password",
)
type args struct {
user *user.User
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "正常系: 指定のユーザーを削除できる",
args: args{
user: deletingUser,
},
wantErr: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
testhelper.SetupFixtures(t,"testdata/fixtures/users.yml")
ctx := context.Background()
if err := userRepository.Delete(ctx, tt.args.user); (err != nil) != tt.wantErr {
t.Errorf("userRepository.Delete()=error:%v, wantErr:%v", err, tt.wantErr)
}
_, err := userRepository.FindByEmail(ctx, deletingUser.GetEmail())
// エラーがあればそれはErrNotFoundUserであるべき
if (err != nil) != tt.wantErr && !errors.Is(err, errors.ErrNotFoundUser) {
t.Errorf("userRepository.FindByEmail()=error:%v, wantErr:%v", err, tt.wantErr)
}
})
}
}
タスクリポジトリのテスト
task_repository_test.go の実装
// app/infrastructure/repository_test/task_repository_test.go
package repository_test
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/kakkky/app/adapter/repository"
"github.com/kakkky/app/domain/errors"
"github.com/kakkky/app/domain/task"
"github.com/kakkky/app/infrastructure/db/sqlc"
testhelper "github.com/kakkky/app/infrastructure/db/testhelper"
)
func TestTaskRepository_Save(t *testing.T) {
taskRepository := repository.NewTaskRepository(sqlc.NewSqlcQuerier())
ta, _ := task.NewTask(
"1",
"content",
"todo",
)
type args struct {
task *task.Task
}
tests := []struct {
name string
args args
want *task.Task
wantErr bool
}{
{
name: "正常系",
args: args{
task: ta,
},
want: ta,
wantErr: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
testhelper.SetupFixtures(t,"testdata/fixtures/users.yml")
ctx := context.Background()
// タスクを保存
if err := taskRepository.Save(ctx, tt.args.task); (err != nil) != tt.wantErr {
t.Errorf("taskRepository.Save() = error %v, wantErr %v", err, tt.wantErr)
}
// FindByIdで検索して確かめる
got, err := taskRepository.FindById(ctx, ta.GetID())
if (err != nil) != tt.wantErr {
t.Errorf("taskRepository.FindById() = error %v,but wantErr :%v", err, tt.wantErr)
}
if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(task.Task{}, task.Content{}), cmpopts.IgnoreFields(task.Task{}, "id")); diff != "" {
t.Errorf("userRepository.FindByEmail() -got,+want :%v ", diff)
}
})
}
}
func TestTaskRepository_FindById(t *testing.T) {
taskRepository := repository.NewTaskRepository(sqlc.NewSqlcQuerier())
task1 := task.ReconstructTask(
"1",
"1",
"content",
0,
)
type args struct {
id string
}
tests := []struct {
name string
args args
want *task.Task
errType error
wantErr bool
}{
{
name: "正常系",
args: args{
id: "1",
},
want: task1,
wantErr: false,
},
{
name: "準正常系: 存在しないタスクIDではErrNotFoundTaskが返る",
args: args{
id: "invalid",
},
errType: errors.ErrNotFoundTask,
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
// task1のみがDBに保存されている
testhelper.SetupFixtures(t,"testdata/fixtures/users.yml", "testdata/fixtures/tasks.yml")
ctx := context.Background()
got, err := taskRepository.FindById(ctx, tt.args.id)
if (err != nil) != tt.wantErr && tt.errType != nil && errors.Is(err, tt.errType) {
t.Errorf("taskRepository.FindById() = error %v, want errType %v", err, tt.errType)
return
}
if (err != nil) != tt.wantErr {
t.Errorf("taskRepository.FindById() = error %v, wantErr %v", err, tt.wantErr)
}
if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(task.Task{}, task.Content{})); diff != "" {
t.Errorf("taskRepository.FindById() -got,+want : %v", diff)
}
})
}
}
func TestTaskRepository_Update(t *testing.T) {
taskRepository := repository.NewTaskRepository(sqlc.NewSqlcQuerier())
// 更新するタスク
updatingTask := task.ReconstructTask(
"1",
"1",
"content",
1,
)
type args struct {
task *task.Task
}
tests := []struct {
name string
args args
want *task.Task
wantErr bool
}{
{
name: "正常系",
args: args{
task: updatingTask,
},
want: updatingTask,
wantErr: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
// 既存のタスクを更新
testhelper.SetupFixtures(t,"testdata/fixtures/users.yml", "testdata/fixtures/tasks.yml")
ctx := context.Background()
if err := taskRepository.Update(ctx, tt.args.task); (err != nil) != tt.wantErr {
t.Errorf("taskRepository.Update() = error %v, wantErr %v", err, tt.wantErr)
}
// FindByIdで検索して確かめる
got, err := taskRepository.FindById(ctx, tt.args.task.GetID())
if (err != nil) != tt.wantErr {
t.Errorf("taskRepository.FindById() = error %v, wantErr %v", err, tt.wantErr)
}
if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(task.Task{}, task.Content{})); diff != "" {
t.Errorf("taskRepository.FindById() -got,+want : %v", diff)
}
})
}
}
func TestTaskRepository_Delete(t *testing.T) {
taskRepository := repository.NewTaskRepository(sqlc.NewSqlcQuerier())
// 更新するタスク
deletingTask := task.ReconstructTask(
"1",
"1",
"content",
1,
)
type args struct {
task *task.Task
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "正常系",
args: args{
deletingTask,
},
wantErr: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
// 既存のタスクを更新
testhelper.SetupFixtures(t,"testdata/fixtures/users.yml", "testdata/fixtures/tasks.yml")
ctx := context.Background()
if err := taskRepository.Delete(ctx, tt.args.task); (err != nil) != tt.wantErr {
t.Errorf("taskRepository.Delete() = error %v, wantErr %v", err, tt.wantErr)
}
// FindByIdで検索して確かめる
_, err := taskRepository.FindById(ctx, tt.args.task.GetID())
if (err != nil) != tt.wantErr && !errors.Is(err, errors.ErrNotFoundTask) {
t.Errorf("taskRepository.FindById()=error:%v, wantErr:%v", err, tt.wantErr)
}
})
}
}
リポジトリのテストを実行する
リポジトリのテストを実行しましょう。
make test-repo
ログが以下のように出ているはずです。
2025/01/13 15:47:19 success to start dockertest container
[mysql] 2025/01/13 15:47:19 connection.go:49: unexpected EOF
[mysql] 2025/01/13 15:47:19 connection.go:49: unexpected EOF
[mysql] 2025/01/13 15:47:19 connection.go:49: unexpected EOF
// 省略
2025/01/13 15:47:29 success to connect test-db
2025/01/13 15:47:29 success to apply migrations
2025/01/13 15:47:29 dockertest & test-db settings complete
// 省略(テスト実行)
PASS
2025/01/13 15:47:29 success to remove dockertest container
CIも通るか確認してみてください。
現段階のディレクトリ
.
├── .github
│ └── workflows
│ └── test.yml
├── .gitignore
├── Dockerfile.app
├── Makefile
├── app
│ ├── .air.toml
│ ├── adapter
│ │ └── repository
│ │ ├── interface_querier.go
│ │ ├── task_repository.go
│ │ └── user_repository.go
│ ├── application
│ │ └── usecase
│ │ ├── task
│ │ │ ├── create_task_usecase.go
│ │ │ ├── create_task_usecase_dto.go
│ │ │ ├── create_task_usecase_test.go
│ │ │ ├── delete_task_usecase.go
│ │ │ ├── delete_task_usecase_dto.go
│ │ │ ├── delete_task_usecase_test.go
│ │ │ ├── fetch_task_usecase.go
│ │ │ ├── fetch_task_usecase_dto.go
│ │ │ ├── fetch_task_usecase_test.go
│ │ │ ├── fetch_tasks_usecase.go
│ │ │ ├── fetch_tasks_usecase_test.go
│ │ │ ├── fetch_user_tasks_usecase.go
│ │ │ ├── fetch_user_tasks_usecase_dto.go
│ │ │ ├── fetch_user_tasks_usecase_test.go
│ │ │ ├── interface_task_query_service.go
│ │ │ ├── mock_task_query_service.go
│ │ │ ├── update_task_state_usecase.go
│ │ │ ├── update_task_state_usecase_dto.go
│ │ │ └── update_task_state_usecase_test.go
│ │ └── user
│ │ ├── fetch_users_usecase.go
│ │ ├── fetch_users_usecase_dto.go
│ │ ├── fetch_users_usecase_test.go
│ │ ├── register_usecase.go
│ │ ├── register_usecase_dto.go
│ │ ├── register_usecase_test.go
│ │ ├── unregister_usecase.go
│ │ ├── unregister_usecase_dto.go
│ │ ├── unregister_usecase_test.go
│ │ ├── update_profile_usecase.go
│ │ ├── update_profile_usecase_dto.go
│ │ └── update_profile_usecase_test.go
│ ├── config
│ │ ├── config.go
│ │ └── config_test.go
│ ├── domain
│ │ ├── errors
│ │ │ ├── errors.go
│ │ │ └── errors_test.go
│ │ ├── task
│ │ │ ├── interface_task_repository.go
│ │ │ ├── mock_task_repository.go
│ │ │ ├── task.go
│ │ │ ├── task_content.go
│ │ │ ├── task_state.go
│ │ │ └── task_test.go
│ │ └── user
│ │ ├── interface_user_domain_service.go
│ │ ├── interface_user_repository.go
│ │ ├── mock_user_domain_service.go
│ │ ├── mock_user_repository.go
│ │ ├── user.go
│ │ ├── user_domain_service.go
│ │ ├── user_domain_service_test.go
│ │ ├── user_email.go
│ │ ├── user_hashed_password.go
│ │ ├── user_test.go
│ │ └── users.go
│ ├── go.mod
│ ├── go.sum
│ ├── infrastructure
│ │ ├── db
│ │ │ ├── connect.go
│ │ │ ├── container
│ │ │ │ ├── db.go
│ │ │ │ └── dockertest.go
│ │ │ ├── migrations
│ │ │ │ ├── 000001_create_users_table.down.sql
│ │ │ │ ├── 000001_create_users_table.up.sql
│ │ │ │ ├── 000002_create_tasks_table.down.sql
│ │ │ │ └── 000002_create_tasks_table.up.sql
│ │ │ ├── mysql
│ │ │ │ └── conf.d
│ │ │ │ └── my.conf
│ │ │ ├── sqlc
│ │ │ │ ├── db.go
│ │ │ │ ├── models.go
│ │ │ │ ├── queries
│ │ │ │ │ ├── tasks.sql
│ │ │ │ │ └── users.sql
│ │ │ │ ├── sqlc.yml
│ │ │ │ ├── sqlc_querier.go
│ │ │ │ ├── tasks.sql.go
│ │ │ │ └── users.sql.go
│ │ │ └── testhelper
│ │ │ └── fixture.go
│ │ ├── kvs
│ │ │ └── redis_client.go
│ │ └── repository_test
│ │ ├── repository_test.go
│ │ ├── task_repository_test.go
│ │ ├── testdata
│ │ │ └── fixtures
│ │ │ ├── tasks.yml
│ │ │ └── users.yml
│ │ └── user_repository_test.go
│ ├── main.go
│ └── tmp
│ ├── build-errors.log
│ └── main
├── compose.yml
├── go.work
├── go.work.sum
└── pkg
├── go.mod
├── go.sum
├── hash
│ ├── hash.go
│ └── hash_test.go
└── ulid
├── ulid.go
└── ulid_test.go