Help us understand the problem. What is going on with this article?

DIを用いたDB(MySQL)の単体テストに #sqlmock を使い、DIのテストしやすさを試す #golang #初心者

概要

golangでデータベースを使ってみようと思ったとき、調べてみるとmain()の中でsql.Open()をしている記事が多いです:ghost:
実際に利用するときは、テストも必要となってくるので、ちょっと考えて実装する必要があります:thinking:
今回、クリーンアーキテクチャっぽくDB部分をDIして、sqlmockを使ったテストを実装しました。
その結果、テスト書きやすーいってなりました:innocent:

クリーンアーキテクチャ

簡単にいうと、アプリケーションを4つのレイヤーに分類して実装するアーキテクチャ。

  • Frameworks & Drivers (フレームワークやデータベース、インフラストラクチャ)
  • Interface Adapters (コントローラ、プレゼンテーション)
  • Application Business Rules (ビジネスロジック)
  • Enterprise Business Rules (エンティティ)

上のレイヤーから下のレイヤーに向かっては依存してもいいが、下のレイヤーから上のレイヤーに依存してはダメ:skull:
下のレイヤーから上のレイヤーを参照する場合は、インターフェースを利用する:thumbsup:

参考

Goで書くClean Architecture API - Qiita

構成

.
├── entity                    # 一番内側の層(エンティティ)
│   └── entity.go
├── infrastructure            # 一番外側の層(インフラストラクチャ)
│   ├── database              # DBの実装
│   │   ├── database.go
│   │   └── database_test.go
│   └── infrastructure.go
├── interface                 # 外から2番目の層
│   ├── adapter               # アダプタ
│   │   └── adapter.go
│   └── controller            # コントローラ
│       └── controller.go
├── main.go
└── usecase                   # 外から3番目の層(ビジネスロジック)
    └── usecase.go

実装

コードベースで説明していきます。
:point_down::point_down::point_down::point_down:実際のコードはこちら:point_down::point_down::point_down::point_down:
https://github.com/hirano00o/godbsample
:point_up_2::point_up_2::point_up_2::point_up_2::point_up_2::point_up_2::point_up_2::point_up_2::point_up_2::point_up_2::point_up_2::point_up_2::point_up_2::point_up_2::point_up_2::point_up_2:

このコードは、単純に名前と年齢をDBに書き込んで、読み出すサンプルとなります。

まずは、クリーンアーキテクチャに沿ってDB実装から追います。

infrastructure/database/database.go
// interfaceに注入するための構造体
type Server struct {
        Conn *sql.DB
}

type Config struct {
        User     string
        Password string
        Host     string
        Port     string
        DBName   string
}

// NewDBでMySQLへのコネクションをinterfaceであるadapter.DBに詰めて返す
func NewDB(cnf Config) adapter.DB {  // adapter.DBはInterfaceAdapters層で定義したinterface
        dbconf := mysql.Config{
                User:                 cnf.User,
                Passwd:               cnf.Password,
                Net:                  "tcp",
                Addr:                 cnf.Host + ":" + cnf.Port,
                DBName:               cnf.DBName,
                AllowNativePasswords: true,
        }
        db, err := sql.Open("mysql", dbconf.FormatDSN())
        if err != nil {
                log.Fatal(err)
        }
        handler := new(Server)
        handler.Conn = db
        // Server{mysql.Conn}をadapter.DB interfaceに注入
        return handler
}

func (s *Server) Get(name string) ([][]interface{}, error) {
        // select処理
}

adapter.DBはどこで定義されているか、Get()はどこで呼ばれているかというと...

interface/adapter/adapter.go
// ↓adapter.DB
// このinterfaceにmysql.Connを注入する。中の関数は上のdatabase.goに実装がある。
type DB interface {
        Set(map[string]string) error
        Get(string) ([][]interface{}, error)
}

type UserAdapter struct {
        DB  // 上記のinterface
}

// usecase層から呼ばれる
func (a *UserAdapter) Find(u entity.User) ([]entity.User, error) {
        // レシーバのinterfaceからdatabaseの実装を呼んでいる
        users, err := a.Get(u.Name)
        // 〜処理〜
}

adapterの関数は、usecaseにinterfaceが作成されて呼ばれている。

usecase/usecase.go
type UserUsecase struct {
    Adp UserAdapter
} 

// 内側の層から外側の層を参照するためにinterfaceを実装している
type UserAdapter interface {
        Store(entity.User) error
        Find(entity.User) ([]entity.User, error)
}

// controllerから呼ばれる
func (u *UserUsecase) ReadUser(name string) {
        user := new(entity.User)
        user.Name = name
        // レシーバが持つアダプタのinterfaceを呼ぶ->Interface Adapters層の実装につながる
        users, err := u.Adp.Find(*user)
        // 〜処理〜
}

外から内側へは依存して良いため、usecaseの関数は、controllerから直接呼ばれる。

interface/controller/controller.go
type UserController struct {
        Interactor usecase.UserUsecase
}

// 各層の構造体を通して、DBの実装にコネクションを渡せるようにする
// 初期化時に実行する
func NewUserController(db adapter.DB) *UserController {
        return &UserController{
                Interactor: usecase.UserUsecase{
                        Adp: &adapter.UserAdapter{
                                DB: db,
                        },
                },
        }
}

func (u *UserController) Read(name string) {
        // レシーバから依存しているusecase層の関数を呼ぶ
        u.Interactor.ReadUser(name)
}

同様にinfrastructureからcontrollerを直接呼んでいる。

infrastructure/infrastructure.go
// main()から呼ぶ
func Route() {
        conf := database.Config{
                User:     os.Getenv("DB_USER"),
                Password: os.Getenv("DB_PASSWORD"),
                Host:     os.Getenv("DB_HOST"),
                Port:     os.Getenv("DB_PORT"),
                DBName:   os.Getenv("DB_NAME"),
        }

        // DBコネクション、コントローラを作成
        c := controller.NewUserController(database.NewDB(conf))
        users := []user{
                user{"Alice", 10},
                // 〜他のユーザ〜
        }

        // 依存しているコントローラの関数を呼ぶ
        // TODO http handlerにして、プレゼンテーション層につなげるようにしたい
        for _, u := range users {
                c.Write(u.name, u.age)
        }
        for _, u := range users {
                c.Read(u.name)
        }
}

DBの単体テスト

sqlmock自体は、下記にexampleがあります。
DATA-DOG/go-sqlmock: Sql mock driver for golang to test database interactions
ざっくり書き方をコード中にコメントで説明していきます。

infrastructure/database/database_test.go
// まずはINSERT OKの場合
func TestOKSet(t *testing.T) {
        db, mock, err := sqlmock.New()    // 新規mockを作成
        if err != nil {
                t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
        }
        defer db.Close()

        // DBの実装と同じ順序で、期待する操作をmockします
        // 今回はトランザクションをBeginしてPreparedStatementを作成して、
        // Execしてコミットしているので、下記の順序となる
        mock.ExpectBegin()
        mock.ExpectPrepare("INSERT INTO USERS").  // INSERTはテーブル名まで書く
            ExpectExec().               // PreparedStatementを実行
            WithArgs("Bob", 10).        // INSERTする値、"Bob"、10歳
            WillReturnResult(           // 返り値はどうなるか
                sqlmock.NewResult(1, 1) // 主キーの自動生成IDと実行して影響を受ける行数を指定
            )
        mock.ExpectCommit()             // Commit

        s := new(Server)
        // mockで作成したDBを入れる
        s.Conn = db
        m := map[string]string{
                "Name": "Bob",
                "Age":  "10",
        }
        if err := s.Set(m); err != nil {
                t.Errorf("error was not expected while insert stats: %s", err)
        }
        // 上で作成したmockの期待は満たせているか確認
        if err := mock.ExpectationsWereMet(); err != nil {
                t.Errorf("there were unfulfilled expectations: %s", err)
        }
}

// INSERT NGの場合(PreparedStatement失敗パターン)
func TestNGSetStmt(t *testing.T) {
        db, mock, err := sqlmock.New()
        if err != nil {
                t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
        }
        defer db.Close()

        // 基本的には同じ
        // PreparedStatementで失敗させたいので、Beginまで書く
        mock.ExpectBegin()

        s := new(Server)
        s.Conn = db
        err = s.Set(nil)
        if err == nil {
                t.Errorf("was expecting an error, but there was none")
        }
        // PreparedStatement以外で失敗していたらエラー
        if strings.Contains(err.Error(), "Prepare") == false {
                t.Errorf("was not expecting an error, but there was Prepare")
        }
}

// SELECT OKのパターン
func TestOKGet(t *testing.T) {
        // 〜mockの新規作成(上と同じ)〜

        // 期待するクエリを書く
        // QuoteMetaを利用すると簡単に書けるらしい
        mock.ExpectQuery(
                regexp.QuoteMeta(`SELECT NAME, AGE FROM USERS WHERE NAME = ?`),
        ).
                WithArgs("Bob").                 // NAME = ?に入れる値
                WillReturnRows(sqlmock.NewRows([]string{"ID", "NAME", "AGE"}).
                        AddRow(1, "Bob", 15),    // 返り値
                )

        s := new(Server)
        s.Conn = db
        res, err := s.Get("Bob")
        if err != nil {
                t.Fatalf("an error '%s' was not expected", err)
        }

        if len(res) != 1 {
                t.Errorf("was not expecting count %d, but there was count 1", len(res))
        }
        // 〜いろいろ確認〜
}

// SELECT NGパターン...?
func TestNGGetConn(t *testing.T) {
        // DBにアクセスしたかったけど、コネクションがないパターン
        _, mock, err := sqlmock.New()
        if err != nil {
                t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
        }

        mock.ExpectQuery(
                regexp.QuoteMeta(`SELECT NAME, AGE FROM USERS WHERE NAME = ?`),
        ).
                WithArgs("Bob").
                WillReturnRows(sqlmock.NewRows([]string{"ID", "NAME", "AGE"}).
                        AddRow(1, "Bob", 15),
                )

        s := new(Server)
        _, err = s.Get("Bob")
        if err != nil {
                t.Fatalf("an error '%s' was not expected", err)
        }
}

まとめ

ポイントとしては下記かなと

  • SELECT等を実行する関数は、テストしやすいように小さくする
  • コネクションは、レシーバか引数で渡せるようにする
    • 今回は、DBの実装を間接的に呼ぶ層の構造体に、各層の構造体を通してDBのコネクションを渡した
  • sqlmockは楽

今回使ったコードはこちらです
:point_right: https://github.com/hirano00o/godbsample
:star2: やpull req頂けるとやる気モリモリになります!:muscle:

golang weeklyでinterfaceの説明とDBのテストしている記事見つけたので共有しておきます
Interfaces Explained

TODO

  • log出力じゃなくて、http handlerにしたい
  • 他の層のテスト
  • redisでの実装
  • コード中のexportする関数等のコメント追加

参考

golangでMySQLのTransactionを実装してみる - Qiita

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした