4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

GolangでのORM使ったDBアクセスロジックのtestcode事例(Functional Option Patternを使ったテストデータ準備関数を用意)

Posted at

お題

表題の通り。※使用する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(~~) 関数を実行した時に、CustomerFirstNameがデフォルトの「"ダミー名"」から、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は分けてる。
(じゃないと、アプリ起動して動作確認してる時とテストコード流してる時とでデータが混ざる。そしてテスト結果が変わりうる。)

db/docker-compose.yml
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を使ってマイグレーション。
マイグレーションファイルは下記。

db/migration/20201206143541-create-table.sql
-- +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;

マイグレーション実行用のシェルは下記。

scripts/sql-migrate-up.sh
#!/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"は何かと言うと、以下で定義した設定。

db/dbconfig.yml
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ファイルを書いて、

src/sqlboiler.toml
output   = "repository"
pkgname  = "repository"

[psql]
  host   = "localhost"
  port   = 22456
  dbname = "tips-go-try-ormtest-db"
  user   = "postgres"
  pass   = "yuckyjuice"
  sslmode= "disable"
  blacklist = [
    "migration",
  ]

以下のシェルから自動生成コマンドを叩くと、

scripts/sqlboiler.sh
#!/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関数

src/cmd/main.go
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形式ですらない。アクセスしても以下のようにそっけなく構造体の中身がブラウザに表示されるだけ。
Screenshot at 2020-12-07 01-14-06.png

カスタマーサービスロジック

インタフェースは下記。

src/customer.go
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) これだけ。

src/adapter/customer.go
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等)用の関数も用意。

src/adapter/main_test.go
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テストコード

src/adapter/customer_test.go
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
}

お題のところでほぼほぼ説明し終わってるのでソースの記載だけ。
これを実行すると以下のように成功する。
Screenshot at 2020-12-07 01-23-49.png

まとめ

まあ、一長一短あるので、ORM使うならすべてこれっていう感じにはならないと思うけど、1事例として参考程度に。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?