RareJob Tech Blog

レアジョブのエンジニア・デザイナーによる技術ブログです

golangでサービスロケーターパターン

コアテクノロジープラットフォーム部・プラットフォームチームの金丸です。
主にgolangを使用したAPI開発を担当しております。

さて現在、一部のプロジェクトでDB等の外部リソースにアクセスするテストを行う際、弊社ではdockertestを使用しています。

コンテナを立ち上げる事で外部リソースへのテストを行う事ができ、便利なのは良いのですが
現状全てのunitテストで使用されているため、上位packageのunit test内でもDBの設定が必要な状態になっています。

そのためmockで置き換える事ができるサービスロケーターパターンを使用した実装へ変更する事にします。

DB

mysql> desc users;
+-------+------------------+------+-----+---------+-------+
| Field | Type             | Null | Key | Default | Extra |
+-------+------------------+------+-----+---------+-------+
| id    | int(10) unsigned | NO   | PRI | NULL    |       |
| name  | varchar(255)     | NO   |     | NULL    |       |
+-------+------------------+------+-----+---------+-------+
2 rows in set (0.03 sec)

mysql> select * from users;
+----+------+
| id | name |
+----+------+
|  1 | test |
+----+------+
1 row in set (0.00 sec)

通常時の確認のためDBを準備します。
idとnameだけのシンプルなテーブルで、テストデータを1レコードだけ投入してあります。

プロジェクトを作成する

~go/src/github.com/hogehoge/locator-go
 ┣ repositories
 ┃ ┣ db.go
 ┃ ┗ db_test.go
 ┗services
   ┣ services.go
   ┗ services_test.go

プロジェクトを作成します。repositories/db.goがDBとアクセスするpackageで、services.service.goが前述のpackageを呼び出します。

DBにアクセスするpackageを作る

db.go

package repositories

import (
    "context"
    "database/sql"
)

// Repositoriesで実装しているインターフェイス
type Repositories interface {
    Search(ctx context.Context, id uint64) (string, error)
    NotUseFunc()
}

// Repositories内のメソッドを使うための構造体
type Repository struct {
    DB *sql.DB
}

// 新規Repositoryオブジェクト作成
func NewRepository(db *sql.DB) Repository {
    return Repository{
        DB: db,
    }
}

// DBから検索するメソッド
func (r Repository) Search(ctx context.Context, id uint64) (name string, err error) {
    err = r.DB.QueryRow("SELECT name FROM students WHERE id = ? LIMIT 1", id).Scan(&name)

    return
}

// mockで置き換えないメソッド
func (r Repository) NotUseFunc() {
    return
}

やってる事はシンプルなSQLを実行しているだけです。
この時実装しているメソッドはRepositoryをレシーバとして設定してあり、これらはRepositoriesインターフェイスを満たしています。

DBにアクセスするpackageを動かしてみる

db_test.go

package repositories

import (
    "context"
    "database/sql"
    "fmt"
    _ "github.com/go-sql-driver/mysql"
    "testing"
)

func TestRepositories_Search(t *testing.T) {
    var (
        cnn  *sql.DB
        err  error
        name string
    )

    cnn, err = sql.Open("mysql", "user:password@tcp(host:port)/db_name")
    if err != nil {
        panic(err)
    }

    name, err = NewRepository(cnn).Search(context.TODO(), 1)
    if err != nil {
        panic(err)
    }
    fmt.Printf("name = %s\n", name)
}

 

=== RUN   TestRepositories_Search
name = test
--- PASS: TestRepositories_Search (0.02s)

簡易テストですので標準出力でログを出しています。
DBの値が取れている事が確認できます。

DBにアクセスするpackageを呼ぶpackageを作る

services.go

package services

import (
    "context"
    "github.com/hogehoge/locator-go/repositories"
)

// Servicesで実装しているインターフェイス
type Services interface {
    SearchService(ctx context.Context, id uint64) (name string, err error)
}

// Services内のメソッドを使うための構造体
type Service struct {
    Repository repositories.Repositories
}

// 新規Serviceオブジェクト作成
func NewService(rep repositories.Repositories) Service {
    return Service{
        Repository: rep,
    }
}

// Repository.Search()を呼ぶメソッド
func (s Service) SearchService(ctx context.Context, id uint64) (string, error) {
    return s.Repository.Search(ctx, id)
}

DBアクセス時と同様にpackageを作成します。
この時レシーバにrepositories.Repositoriesインターフェイス型の変数を実装しておき、メソッド内部ではそれを使用してSearch()を呼ぶ様にしています。

DBにアクセスするpackageをmock化して呼ぶpackageのテストを作る

services_test.go

package services

import (
    "context"
    "fmt"
    "github.com/hogehoge/locator-go/repositories"
    "testing"
)

// 置き換えるmockオブジェクト
type mockRepository struct {
    repositories.Repositories // インターフェイス埋め込み
    rName string              // result用name変数
    rErr  error               // result用err変数
}

// overrideするメソッド
func (m mockRepository) Search(ctx context.Context, id uint64) (string, error) {
    return m.rName, m.rErr
}

func TestService_SearchService(t *testing.T) {
    mock := mockRepository{
        rName: "mockName",
    }
    // mockで置き換える
    service := NewService(mock)

    // mockが渡されているためoverrideしたメソッドが呼ばれる
    name, err := service.SearchService(context.TODO(), 1)
    if err != nil {
        panic(err)
    }
    if name != mock.rName {
        panic("name is not equal")
    }
    fmt.Printf("name = %s\n", name)
}
  • 置き換えるmockオブジェクトを作成します
    • この時インターフェイスを埋め込んでおく事で、テストに関係のないメソッドを実装しなくて良くなります
  • mock化するメソッドを作成する
    • mockオブジェクトをレシーバとするメソッドを作成します。インターフェイス通りに実装しましょう
    • 結果を入れる変数を返す様にすればテスト毎に望む結果が返せて便利です
  • テストを実装する
    • Service.Repositoryオブジェクトにmockを入れます
    • mockが入ったService.RepositoryをNewServiceに受け渡します
    • これ以降、テスト内ではmock内のメソッドが呼ばれるようになります
=== RUN   TestService_SearchService
name = mockName
--- PASS: TestService_SearchService (0.00s)

これでRepositoriesを呼んでいるパッケージはテスト時にmockに置き換えられる様になりました。
ビューなどを実装する場合は、さらにServicesを同じ要領で置き換えてください。
これで実際にDBに繋げてテストする部分はrepositoriesだけになります。

もっと良いやり方や上手い実装があればウチに入社してこっそりマージリクエストください。