9
3

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 5 years have passed since last update.

GoでDBアクセスするコードのテストを書いてみよう

Last updated at Posted at 2019-05-07

Voicy3月入社の鍛治です。元々RubyとRailsでWebアプリケーションを作って仕事をしていましたが、Voicyに入社してから専らGoでWeb APIを開発しています。Rubyと比べると違いが沢山あり日々勉強になっています^^;

TL;DR

  • DBアクセスのテストを書くならテスト前後でDBのデータを初期化しよう
  • テストコードをDRYにするために testifyのsuiteパッケージを積極的に使おう

背景

Voicyでは現在複数のサービスのバックエンドのドメインロジックを統合するプロジェクトを行っております(詳細はこちらを御覧ください)。
以下の理由でテストコードも記述しています。

  • 事前にコードのバグを見つけやすくするため
  • 自分の書いたコードの不安を取り除くため

リポジトリ層のテストどうしよう問題

テストコードを書いていく上でリポジトリ層(データストアにアクセスする層)のメソッドのテストをどう書いていくかという問題に直面しました。データストアはMySQLを使っているので、その部分をどういう感じで書いていこうかという話ですね。

テストコードの流れる時間を短縮したい等の理由でDBアクセスの部分は完全にモックにしてしまうという考えもあるかもしれませんが、以下の理由で実際にDBに繋いでテストコードを書くことにしました。

  • 自分の書いたSQLが正しい挙動をしているのか把握したい
  • 複雑なSQLを書いた場合、動作確認用のデータを毎回作るのが大変であるため

具体的な実装例

テスト対象のコード

実際にテストコードを書いていく前にテストする対象のコードを最初に出したいと思います。今回は記事をMySQLから1件取得したりリスト取得するコードになります。

package datastore

import (
	"time"

	"github.com/jmoiron/sqlx"
)

type Post struct {
	ID        int       `db:"id"`
	Title     string    `db:"title"`
	Body      string    `db:"body"`
	IsDeleted bool      `db:"is_deleted"`
	CreatedAt time.Time `db:"created_at"`
	UpdatedAt time.Time `db:"updated_at"`
}

type PostRepository interface {
	// 表示可能な記事をidから1件取得
	GetByID(id int) (*Post, error)
	// 表示可能な記事をidの降順で取得
	List() ([]*Post, error)
}

type postRepository struct {
	db *sqlx.DB
}

func NewPostRepository(db *sqlx.DB) PostRepository {
	return &postRepository{db}
}

func (r *postRepository) GetByID(id int) (*Post, error) {
	var post Post
	err := r.db.Get(&post, "select * from posts where id=? and is_deleted=0 limit 1", id)
	if err != nil {
		return nil, err
	}
	return &post, nil
}

func (r *postRepository) List() ([]*Post, error) {
	posts := []*Post{}
	err := r.db.Select(&posts, "select * from posts where is_deleted=0 order by id desc")
	if err != nil {
		return nil, err
	}
	return posts, nil
}

テストコード

上のコードのインターフェースのメソッドを実際にテストしたコードが以下です。

package datastore

import (
	"testing"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
	"github.com/khaiql/dbcleaner"
	"github.com/stretchr/testify/suite"
	"gopkg.in/khaiql/dbcleaner.v2/engine"
)

const dataSourceName = "dataSourceName"

type DatabaseTestSuite struct {
	suite.Suite

	db      *sqlx.DB
	cleaner dbcleaner.DbCleaner
}

type PostRepositoryTestSuite struct {
	DatabaseTestSuite
}

func TestPostRepositoryTestSuite(t *testing.T) {
	suite.Run(t, new(PostRepositoryTestSuite))
}

func (s *DatabaseTestSuite) setupDB() (*sqlx.DB, error) {
	return sqlx.Open("mysql", dataSourceName)
}

func (s *DatabaseTestSuite) setupDBCleaner() dbcleaner.DbCleaner {
	cleaner := dbcleaner.New()
	mysql := engine.NewMySQLEngine(dataSourceName)
	cleaner.SetEngine(mysql)
	return cleaner
}

// スイートのセットアップ時にDBとクリーナーをセットする
func (s *DatabaseTestSuite) SetupSuite() {
	db, err := s.setupDB()
	s.Require().NoError(err)
	s.db = db
	s.cleaner = s.setupDBCleaner()
}

// テストのセットアップ時にDBに繋いでデータを空にしておく
func (s *PostRepositoryTestSuite) SetupTest() {
	s.cleanData()
	s.insertData()
}

// テスト実行後にデータを掃除
func (s *PostRepositoryTestSuite) TearDownTest() {
	s.cleanData()
}

// クリーナーとDBを閉じる
func (s *DatabaseTestSuite) TearDownSuite() {
	s.cleaner.Close()
	s.db.Close()
}

func (s *PostRepositoryTestSuite) insertData() {
	posts := []Post{
		Post{ID: 1, Title: "title1", Body: "body1", IsDeleted: true},
		Post{ID: 2, Title: "title2", Body: "body2", IsDeleted: false},
		Post{ID: 3, Title: "title3", Body: "body3", IsDeleted: false},
	}
	for _, p := range posts {
		_, err := s.db.Exec("insert into posts (id, title, body, is_deleted) values (?, ?, ?, ?)", p.ID, p.Title, p.Body, p.IsDeleted)
		s.Require().NoError(err)
	}
}

func (s *PostRepositoryTestSuite) cleanData() {
	s.cleaner.Clean("posts")
}

func (s *PostRepositoryTestSuite) TestPostRepositoryGetByID() {
	repo := NewPostRepository(s.db)
	got, err := repo.GetByID(2)

	s.Assert().NoError(err)
	s.Assert().Equal(2, got.ID)
	s.Assert().Equal("title2", got.Title)
	s.Assert().Equal("body2", got.Body)
	s.Assert().Equal(false, got.IsDeleted)
}

func (s *PostRepositoryTestSuite) TestPostRepositoryList() {
	repo := NewPostRepository(s.db)
	got, err := repo.List()

	s.Assert().NoError(err)
	s.Assert().Equal(2, len(got))
	s.Assert().Equal(3, got[0].ID)
	s.Assert().Equal(2, got[1].ID)
}

意識したことは

  • DBの接続やクリーナーの設定は testifyのsuiteパッケージ で提供されているメソッド内で行っています
    • 今後テストファイルが増えることを考慮して、PostRepositoryTestSuite よりも抽象度の高い DatabaseTestSuite を定義してそこで SetupSuite , TearDownSuite を実行しています
  • テストデータの消去は DbCleaner を使用しています
    • 複数のテーブルを削除したい時は Clean メソッドの引数を増やすだけで済みます
  • 各テスト実行前にデータを消してからテストデータを入れて、テスト終了後にもデータを消してます
    • データを消すタイミングは人により好みがあると思いますが、僕はテスト流す前後でやっています^^;
  • 単一データを返すテストでは、データの中身まで詳細にチェックしますが、リストを返すときは順序をチェックしたいのでIDだけのチェックにしています

まとめ

以上、Goで作るリポジトリ層のテストコードの書き方でした。
Rubyだと factory_bot , database_cleaner のようなある程度スタンダードなものがありますが、Goだとあまりそういうのは無いのかなと思ったので、自分でゼロベースで作っていく必要があるなぁと思いました。

今回の実装に関して何かアドバイスやこれ使うと良いよみたいなものがあれば色々教えて頂けるとありがたいです。もし話を聞きに行きたい方がいましたら wantedly からポチッと「応援する」「話を聞きに行きたい」を押してもらえるととても嬉しいです!

9
3
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
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?