お題
表題の通り。※使用するORMはSQL Boilerだけど、他のORM(例えばGorm)でも適用方法は同じだと思う。
指定のテーブルから複数レコードを取得するような関数があったとして、そのtestcodeの事例。
例えば以下のようなコードでレコードを登録しておく。
func TestCustomers(t *testing.T) {
〜〜省略〜〜
c := repository.Customer{
ID: 1,
FirstName: "Satoru",
LastName: "Sato",
Age: 30,
Nickname: null.StringFrom("toru"),
Memo: null.StringFrom("メモ"),
}
if err := c.Insert(ctx, db, boil.Infer()); err != nil {
t.Fatal(err)
}
〜〜省略〜〜
// test
}
テストケースで必要な分だけ、この手のコードを書いていく必要があってツラい。
それに、ケースによっては大抵のカラムは固定値でよくって指定のカラムだけバリエーションを持たせたいこともある。
当然、単に以下のように生成関数に逃しただけでは他のテストケースで使う時に使えないケースが多々でてきてダメ。
func CreateCustomer(ctx context.Context, db *sqlx.DB) error {
c := repository.Customer{
ID: 1,
FirstName: "Satoru",
LastName: "Sato",
Age: 30,
Nickname: null.StringFrom("toru"),
Memo: null.StringFrom("メモ"),
}
if err := c.Insert(ctx, db, boil.Infer()); err != nil {
return err
}
return nil
}
じゃあ、どのようなケースでも対応できるように、設定できるカラムは全て引数に持たせてみると、
func CreateCustomer(ctx context.Context, db *sqlx.DB, id int64, firstName, lastName string, age int, nickname, memo string) error {
c := repository.Customer{
ID: id,
FirstName: firstName,
LastName: lastName,
Age: age,
Nickname: null.StringFrom(nickname),
Memo: null.StringFrom(memo),
}
if err := c.Insert(ctx, db, boil.Infer()); err != nil {
return err
}
return nil
}
ないし、
func CreateCustomer(ctx context.Context, db *sqlx.DB, c repository.Customer) error {
if err := c.Insert(ctx, db, boil.Infer()); err != nil {
return err
}
return nil
}
みたいになって、結局、呼び出し元の負荷が変わってないのでは?となってしまう。
「じゃあ、とりあえず、外からパラメータ渡したいカラムだけ引数にしようか」と思い、以下のように「ID」と「名前」だけパラメータにすると、
func CreateCustomer(ctx context.Context, db *sqlx.DB, id int64, firstName, lastName string) error {
c := repository.Customer{
ID: id,
FirstName: firstName,
LastName: lastName,
Age: 30,
Nickname: null.StringFrom("toru"),
Memo: null.StringFrom("メモ"),
}
if err := c.Insert(ctx, db, boil.Infer()); err != nil {
return err
}
return nil
}
あとから別のテストケースを書く時に「あっ、Age
が10代と20代の時とで挙動が違うケースのテストデータ準備したいからAge
もパラメータに追加したい!」となり、関数のI/Fが変わり、呼び元すべて修正という羽目になる。
とりあえず、今あるテストケースで必要なカラムの分だけパラメータにしておきつつも、あとから呼び出し元を変えることなく必要に応じてパラメータを追加したい。
というわけで、Functional Option Pattern(※Go使いには有名なパターンだと思うので特に説明は無しで)を使ってみる。
すると、以下のようになる。
func CreateCustomer(ctx context.Context, db *sqlx.DB, opts ...CustomerOption) error {
c := repository.Customer{
FirstName: "ダミー名",
LastName: "ダミー姓",
Age: 99,
}
for _, o := range opts {
o(&c)
}
if err := c.Insert(ctx, db, boil.Infer()); err != nil {
return err
}
return nil
}
goは可変長引数が使える。なので opts ...CustomerOption
のようにオプションとして上書きしたいパラメータを1つの共通の型で定義してやり、それを0〜任意の数だけ渡せる。
大事なのは、0(つまり渡さなくてもいい)がOKだと言うこと。
※CustomerOption
については後述。
c := repository.Customer{
FirstName: "ダミー名",
LastName: "ダミー姓",
Age: 99,
}
で、NOT NULLカラムにだけデフォルト値を設定しておく。(※IDはPKでありPostgreSQLからシリアル採番させる。)
そして、そのあとの↓によって、渡されたパラメータの分だけ、カラムを上書きしていく。
for _, o := range opts {
o(&c)
}
これも、ここだけ見てても「何のこっちゃ?」な気がするので、そろそろ CustomerOption
の定義を載せることにする。
type CustomerOption func(*repository.Customer)
関数型にしている。つまり、 for _, o := range opts {
の opts
は関数が複数ループしてるということ。
なので、o(&c)
なんてコードになる。(渡した複数の関数の1つ1つに Customer
構造体の参照を引数として渡している。)
じゃあ、CustomerOption
って具体的に何が渡ってくるのかと言うと、たとえば以下。
func withFirstName(firstName string) CustomerOption {
return func(c *repository.Customer) {
c.FirstName = firstName
}
}
これを渡すと、いざ CreateCustomer(~~)
関数を実行した時に、Customer
のFirstName
がデフォルトの「"ダミー名"」から、firstName
として渡した名前に変わる。
CustomerOption
は可変長引数でいくつでも渡せるようになっているので、LastName
もデフォルトから変えたいと思った時は以下も渡せばいい。
func withLastName(lastName string) CustomerOption {
return func(c *repository.Customer) {
c.LastName = lastName
}
}
これにより、もし、対象のテーブルにカラムが増えて、それをテストケースでパラメータを渡したいとなった時も、上記のように withXXXX(~~)
を追加実装して、必要なテストケースでだけ使えばいい。
ちなみに、実際のテストケースではこのように使う。
// オプション未指定のデフォルトCustomerを生成(1レコード目なのでIDは 1 になるはず)
if err := CreateCustomer(ctx, db); err != nil {
t.Fatal(err)
}
// 全てのパラメータを上書きしてCustomerを生成
if err := CreateCustomer(ctx, db,
withID(2),
withFirstName("Satoru"),
withLastName("Sato"),
withAge(30),
withNickname("toru"),
withMemo("メモ")); err != nil {
t.Fatal(err)
}
以上で、今回の主題としては書ききってしまった。。。
前提
以下は済んだ上での作業。
- Golangのローカルでの開発環境構築
開発環境
# OS
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="20.04.1 LTS (Focal Fossa)"
# Golang
$ go version
go version go1.15.2 linux/amd64
# IDE(Goland)
GoLand 2020.3
Build #GO-203.5981.98, built on November 25, 2020
# Database
postgres:13
# DB管理環境(DataGrip)
DataGrip 2020.3
Build #DB-203.5981.102, built on November 25, 2020
実践
ソース全量は下記。
https://github.com/sky0621/tips-go/tree/v0.2.0/try/ormtest
言いたきことは「お題」ですべて言ってしまったので、以降は、ひたすら書いたソースを記載するだけ。。。
tree
$ tree
.
├── db
│ ├── dbconfig.yml
│ ├── docker-compose.yml
│ ├── insert.sql
│ ├── local
│ │ ├── data
│ │ └── testdata
│ └── migration
│ └── 20201206143541-create-table.sql
├── scripts
│ ├── sql-migrate-new.sh
│ ├── sql-migrate-up.sh
│ └── sqlboiler.sh
└── src
├── adapter
│ ├── customer.go
│ ├── customer_test.go
│ └── main_test.go
├── cmd
│ └── main.go
├── customer.go
├── go.mod
├── go.sum
├── repository
│ ├── boil_main_test.go
│ ├── boil_queries.go
│ ├── boil_queries_test.go
│ ├── boil_suites_test.go
│ ├── boil_table_names.go
│ ├── boil_types.go
│ ├── customer.go
│ ├── customer_test.go
│ ├── psql_main_test.go
│ ├── psql_suites_test.go
│ └── psql_upsert.go
└── sqlboiler.toml
DB
docker-composeで立ち上げる。
普通にアプリ起動した時につなぐDBとテストコードからアクセスするDBは分けてる。
(じゃないと、アプリ起動して動作確認してる時とテストコード流してる時とでデータが混ざる。そしてテスト結果が変わりうる。)
version: '3'
services:
db:
restart: always
image: postgres:13-alpine
container_name: tips-go-try-ormtest-db-postgres-container
ports:
- "22456:5432"
environment:
- DATABASE_HOST=localhost
- POSTGRES_DB=tips-go-try-ormtest-db
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=yuckyjuice
- PGPASSWORD=yuckyjuice
volumes:
- ./local/data:/docker-entrypoint-initdb.d/
testdb:
restart: always
image: postgres:13-alpine
container_name: tips-go-try-ormtest-testdb-postgres-container
ports:
- "33456:5432"
environment:
- DATABASE_HOST=localhost
- POSTGRES_DB=tips-go-try-ormtest-testdb
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=yuckyjuice
- PGPASSWORD=yuckyjuice
volumes:
- ./local/testdata:/docker-entrypoint-initdb.d/
テーブル作成
Go製のsql-migrateを使ってマイグレーション。
マイグレーションファイルは下記。
-- +migrate Up
CREATE TABLE customer (
id bigserial NOT NULL,
first_name varchar(32) NOT NULL,
last_name varchar(32) NOT NULL,
age int NOT NULL,
nickname varchar(64),
memo text,
PRIMARY KEY (id)
);
-- +migrate Down
DROP TABLE customer;
マイグレーション実行用のシェルは下記。
#!/usr/bin/env bash
set -euox pipefail
SCRIPT_DIR=$(dirname "$0")
echo "${SCRIPT_DIR}"
cd "${SCRIPT_DIR}" && cd ../db
# https://github.com/rubenv/sql-migrate
#go get -v github.com/rubenv/sql-migrate/...
sql-migrate up -env="local"
sql-migrate up -env="localtest"
上記の -env=
にて指定された "local"
と"localtest"
は何かと言うと、以下で定義した設定。
local:
dialect: postgres
datasource: host=localhost port=22456 dbname=tips-go-try-ormtest-db user=postgres password=yuckyjuice sslmode=disable
dir: migration
table: migration
localtest:
dialect: postgres
datasource: host=localhost port=33456 dbname=tips-go-try-ormtest-testdb user=postgres password=yuckyjuice sslmode=disable
dir: migration
table: migration
アプリ起動用とテスト用の2つのDBそれぞれにマイグレーションを流す(つまり、customer
テーブルを作る)ということ。
ORマッパー
今回使っているSQL Boilerは、既にDBにテーブルが存在する場合、そこからテーブル・カラム情報を拾って、自動でGo用の構造体やアクセスロジックを生成してくれる。
以下のようなTOMLファイルを書いて、
output = "repository"
pkgname = "repository"
[psql]
host = "localhost"
port = 22456
dbname = "tips-go-try-ormtest-db"
user = "postgres"
pass = "yuckyjuice"
sslmode= "disable"
blacklist = [
"migration",
]
以下のシェルから自動生成コマンドを叩くと、
#!/usr/bin/env bash
set -euox pipefail
SCRIPT_DIR=$(dirname "$0")
echo "${SCRIPT_DIR}"
cd "${SCRIPT_DIR}" && cd ../src
rm -rf ./repository/*
sqlboiler --wipe psql
以下のように各種Goファイルが自動生成される。(各ソースの説明は割愛)
└── src
├── repository
│ ├── boil_main_test.go
│ ├── boil_queries.go
│ ├── boil_queries_test.go
│ ├── boil_suites_test.go
│ ├── boil_table_names.go
│ ├── boil_types.go
│ ├── customer.go
│ ├── customer_test.go
│ ├── psql_main_test.go
│ ├── psql_suites_test.go
│ └── psql_upsert.go
アプリケーションコード
main関数
package main
import (
"fmt"
"log"
"net/http"
"github.com/sky0621/tips-go/try/ormtest/src/adapter"
"github.com/go-chi/chi"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
func main() {
// MEMO: ローカルでしか使わないので、ベタ書き
dsn := "host=localhost port=22456 dbname=tips-go-try-ormtest-db user=postgres password=yuckyjuice sslmode=disable"
db, err := sqlx.Connect("postgres", dsn)
if err != nil {
log.Fatal(err)
}
defer func() {
if db != nil {
// It is rare to Close a DB, as the DB handle is meant to be long-lived and shared between many goroutines.
if err := db.Close(); err != nil {
log.Fatal(err)
}
}
}()
customerService := adapter.NewCustomerService(db)
r := chi.NewRouter()
r.HandleFunc("/customers", func(w http.ResponseWriter, r *http.Request) {
customers, err := customerService.Customers(r.Context())
if err != nil {
if _, err := w.Write([]byte(err.Error())); err != nil {
log.Fatal(err)
}
}
for _, c := range customers {
if _, err := w.Write([]byte(fmt.Sprintf("%#+v\n", c))); err != nil {
log.Fatal(err)
}
}
})
if err := http.ListenAndServe(":8080", r); err != nil {
panic(err)
}
}
DBアクセス用の構造体を準備して、それをサービス(CustomerService
)に渡す。
アプリはWebAPIを提供するサーバとして起動する。現状提供しているのは「"/customers"
」というパスにアクセスした時に全カスタマー情報をレスポンスに書き込む機能だけ。
※今回の主題と関係ないのでレスポンスはJSON形式ですらない。アクセスしても以下のようにそっけなく構造体の中身がブラウザに表示されるだけ。
カスタマーサービスロジック
インタフェースは下記。
package ormtest
import (
"context"
)
type CustomerService interface {
Customers(context.Context) ([]*Customer, error)
}
type Customer struct {
ID int64 `json:"id"`
FullName string `json:"fullName"`
Age int `json:"age"`
Nickname string `json:"nickname,omitempty"`
Memo string `json:"memo,omitempty"`
}
実装は下記。今回の主題では、この Customers(ctx context.Context) ([]*ormtest.Customer, error)
がテスト対象。
単にDBから全customer
レコードを取得して指定の構造体に変換して返すだけ。
SQL Boilerを使っているので実際にDBにアクセスするコードは models, err := repository.Customers().All(ctx, a.db)
これだけ。
package adapter
import (
"context"
"fmt"
"github.com/sky0621/tips-go/try/ormtest/src/repository"
"github.com/jmoiron/sqlx"
ormtest "github.com/sky0621/tips-go/try/ormtest/src"
)
func NewCustomerService(db *sqlx.DB) ormtest.CustomerService {
return &customerAdapter{db}
}
type customerAdapter struct {
db *sqlx.DB
}
func (a *customerAdapter) Customers(ctx context.Context) ([]*ormtest.Customer, error) {
models, err := repository.Customers().All(ctx, a.db)
if err != nil {
return nil, err
}
results := []*ormtest.Customer{}
for _, model := range models {
results = append(results, &ormtest.Customer{
ID: model.ID,
FullName: fmt.Sprintf("%s %s", model.LastName, model.FirstName),
Age: model.Age,
Nickname: model.Nickname.String,
Memo: model.Memo.String,
})
}
return results, nil
}
テストコード
パッケージテスト用コード
テスト用DB接続や後始末(対象テーブルデータのtruncate等)用の関数も用意。
package adapter
import (
"fmt"
"log"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
func TestMain(m *testing.M) {
db := setupDB()
defer teardownDB(db)
m.Run() // go v1.15 からは os.Exit 不要らしい
}
func setupDB() *sqlx.DB {
// MEMO: ローカルでしか使わないので、ベタ書き
dsn := "host=localhost port=33456 dbname=tips-go-try-ormtest-testdb user=postgres password=yuckyjuice sslmode=disable"
db, err := sqlx.Connect("postgres", dsn)
if err != nil {
log.Fatal(err)
}
return db
}
func teardownDB(db *sqlx.DB) {
defer func() {
if err := db.Close(); err != nil {
log.Println(err)
}
}()
rows, err := db.Queryx(`SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'public' AND tablename != 'migration'`)
if err != nil {
log.Println(err)
return
}
for rows.Next() {
var tableName string
if err := rows.Scan(&tableName); err != nil {
log.Println(err)
return
}
// RESTART IDENTITY ... 消去されるテーブルの列により所有されるシーケンスを自動的に再起動させます。
// CASCADE ... 指定されたテーブル、または、CASCADEにより削除対象テーブルとされたテーブルを参照する外部キーを持つテーブルすべてを自動的に空にします。
db.MustExec(fmt.Sprintf("TRUNCATE TABLE %s RESTART IDENTITY CASCADE", tableName))
}
}
customerテストコード
package adapter
import (
"context"
"testing"
"github.com/jmoiron/sqlx"
"github.com/google/go-cmp/cmp"
ormtest "github.com/sky0621/tips-go/try/ormtest/src"
"github.com/sky0621/tips-go/try/ormtest/src/repository"
"github.com/volatiletech/null/v8"
"github.com/volatiletech/sqlboiler/v4/boil"
)
func TestCustomers(t *testing.T) {
db := setupDB()
defer teardownDB(db)
service := NewCustomerService(db)
ctx := context.Background()
tests := []struct {
name string
prepareFunc func()
want []*ormtest.Customer
wantError bool
}{
/*
* TODO: テストケースは、この順番じゃないと成功しない。本当は"no records"と"some records"はテストケースを分けるべき。
*/
{
name: "no records",
prepareFunc: func() {},
want: []*ormtest.Customer{},
wantError: false,
},
{
name: "some records",
prepareFunc: func() {
// オプション未指定のデフォルトCustomerを生成(1レコード目なのでIDは 1 になるはず)
if err := CreateCustomer(ctx, db); err != nil {
t.Fatal(err)
}
// 全てのパラメータを上書きしてCustomerを生成
if err := CreateCustomer(ctx, db,
withID(2),
withFirstName("Satoru"),
withLastName("Sato"),
withAge(30),
withNickname("toru"),
withMemo("メモ")); err != nil {
t.Fatal(err)
}
},
want: []*ormtest.Customer{
{
ID: 1,
FullName: "ダミー姓 ダミー名",
Age: 99,
},
{
ID: 2,
FullName: "Sato Satoru",
Age: 30,
Nickname: "toru",
Memo: "メモ",
},
},
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.prepareFunc()
got, err := service.Customers(ctx)
if (err != nil) != tt.wantError {
t.Errorf("error = %v, wantError = %v", err, tt.wantError)
return
}
opts := []cmp.Option{}
if diff := cmp.Diff(tt.want, got, opts...); diff != "" {
t.Errorf("unmatch (-want +got):\n%s", diff)
return
}
})
}
}
type CustomerOption func(*repository.Customer)
func withID(id int64) CustomerOption {
return func(c *repository.Customer) {
c.ID = id
}
}
func withFirstName(firstName string) CustomerOption {
return func(c *repository.Customer) {
c.FirstName = firstName
}
}
func withLastName(lastName string) CustomerOption {
return func(c *repository.Customer) {
c.LastName = lastName
}
}
func withAge(age int) CustomerOption {
return func(c *repository.Customer) {
c.Age = age
}
}
func withNickname(nickname string) CustomerOption {
return func(c *repository.Customer) {
c.Nickname = null.StringFrom(nickname)
}
}
func withMemo(memo string) CustomerOption {
return func(c *repository.Customer) {
c.Memo = null.StringFrom(memo)
}
}
func CreateCustomer(ctx context.Context, db *sqlx.DB, opts ...CustomerOption) error {
c := repository.Customer{
FirstName: "ダミー名",
LastName: "ダミー姓",
Age: 99,
}
for _, o := range opts {
o(&c)
}
if err := c.Insert(ctx, db, boil.Infer()); err != nil {
return err
}
return nil
}
お題のところでほぼほぼ説明し終わってるのでソースの記載だけ。
これを実行すると以下のように成功する。
まとめ
まあ、一長一短あるので、ORM使うならすべてこれっていう感じにはならないと思うけど、1事例として参考程度に。