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 からポチッと「応援する」「話を聞きに行きたい」を押してもらえるととても嬉しいです!