テスト可能 個人開発でクリーンアーキテクチャ...の補足
前置き
こちらの記事の補足記事となります。
思っている以上に長くなりそうだったので別記事としました。
テスト可能について言及したいと思います。
みなさんは go でテストする際どのようなライブラリを使用していますか?
最近 ginkgo を使用し始めました。
自分の性格を考えると最終的に testing オンリーに回帰しそうです。
今回はとりあえず assert 用に testify を DB のモックとして sqlmock を使っていこうと思います。
参考
table driven test
リポジトリ層のユニットテスト
DB とのやり取りをする実処理の部分をテストします。
MVC で言うと models 層、クリーンアーキテクチャで言うとリポジトリ層になります。
MVC
package models
import (
".../connector"
)
// blogの定義
type TBlog struct {
ID int `json:":id"`
Title string `json:"title"`
Content string `json:"content"`
}
// 全権取得は省略
// blog一件取得
func BlogDetail(id int) (b []TBlog, err error) {
db := connector.AccessDB()
defer db.Close()
rows, err := db.Query("SELECT * FROM blogs WHERE id = ?", id)
rows.Next()
err := rows.Scan(
&b.ID, &b.Title, &b.Content,
)
return
}
実際にテストは書きませんが、BlogDetail をほとんど模写するような形でモックを作らなければいけないのが一目でわかりますよね。
BlogDetail の引数に*sql.DB の型の変数を加えれば少しはマシになりますが、、、って感じです。
クリーンアーキテクチャ
一方でこちらは簡単です。少々面倒なのがどうしても DB とのやり取りの層なので DB のモックを作成しなければならない点です。まあ、こちらは MVC にも共通します。
まずこちらがテストの対象になるコードです。
package databse
import ".../domain"
// blog repositoryはSqlHandlerを所持する
type BlogRepository struct {
SqlHandler
}
func (repo *BlogRepository) FindById(id int) (b []domain.TBlog, err error) {
rows, err := repo.Query(
"SELECT * from blogs WHERE id = ?", id,
)
defer rows.Close()
rows.Next()
err := rows.Scan(
&b.ID, &b.Title, &b.Content,
)
return
}
テスト用にダミー SQL ハンドラー関数を作成した上で実装していきます。
見てわかるとおりけっこう長くて大変です。
package database_test
import (
".../domain"
".../infrastructure"
".../interfaces/database"
"database/sql"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
)
// ダミーハンドラー生成関数
// どこか違うとこで定義して使いまわす方がお得
func newDummyHandler(db *sql.DB) database.SqlHandler {
sqlHandler := new(infrastructure.SqlHandler)
sqlHandler.Conn = db
return sqlHandler
}
func TestFindBlogById(t *testing.T) {
// table for test
table := []struct {
testName string
id int
blog domain.TBlog
err error
} {
{
"success",
30,
domain.TBlog{
ID: 30,
Title: "ブログその30",
Content: "ブログの本文\n今日も沢山プログラミングした。"
},
nil,
},
}
/* prepare */
db, mock, err := sqlmock.New()
if err != nil {
t.Error("sqlmock not work")
}
defer db.Close()
repo := &database.ReviewRepository{
SqlHandler: newDummyHandler(db)
}
query := "SELECT * from blogs WHERE id = ?"
for _, tt := range table {
t.Run(tt.testName, func(t *testing.T) {
b := tt.blog
rows := sqlmock.NewRows([]string{
"id", "title", "content",
}). AddRow(b.ID, b.Title, b.Content)
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(tt.id).WillReturnRows(rows)
got, err := repo.FindById(b.ID)
assert.Equal(t, tt.err, err)
assert.Equal(t, b, got)
})
}
}
ユースケース層のテスト
テスト可能で真価が発揮されるのはこちらです。
テストの対象となるのはこちらのコードです。
package usecase
import ".../domain"
type BlogInteractor struct {
repository BlogRepository
}
func NewBlogInteractor(blog BlogRepository) domain.BlogInteractor {
return &BlogInteractor{
repository: blog,
}
}
/************************
repository
************************/
type BlogRepository interface {
FindById(int) (domain.TBlog, error)
}
/**********************
interactor methods
***********************/
func (interactor *BlogInteractor) DetailBlog(id int) (blog domain.TBlog, err error) {
blog, er = interactor.repository.FindById(id)
return
}
DetailBlog という関数をテストします。
モックは mock を使わずに手作りします。とても簡単に作れます。
package usecase_test
import (
".../domain"
".../database"
".../usecase"
"testing"
"github.com/stretchr/testify/assert"
)
// BlogRepositoryインターフェースを満たすfakeBlogRepositoryを作る
type blogRepository struct {
database.BlogRepository
}
var (
detailTable = []struct {
testName string
id int
blog domain.TBlog
err error
} {
{
"success",
30,
domain.TBlog{
ID: 30,
Title: "ブログその30",
Content: "ブログの本文\n今日も沢山プログラミングした。"
},
nil,
},
}
)
// FindByIdメソッドの実装を定義
func (repo *blogRepository) FindById(id int) (domain.TBlog, error) {
for _, t := range detailTable {
if r.id == id {
return t.blog, nil
}
}
return domain.TBlog{}, errors.New("not found")
}
// test
func TestDetailBlog(t *testing.T) {
mockBlogRepo := new(blogRepository)
interactor := usecase.NewBlogInteractor(mockBlogRepo)
for _, tt := range detailTable {
t.Run(tt.testName, func(t *testing.T) {
b, err := interactor.DetailBlog(tt.id)
assert.Equal(t, tt.err, err)
assert.Equal(t, tt.blog, b)
})
}
}
このテストで BlogInteractor の DetailBlog メソッドを実行した際に、BlogRepository の FindById メソッドが呼び出されていることがわかりました。
たったこれだけでテストができてしまいます。
非常に簡単ではないでしょうか?
インターフェース層(コントローラー)のテスト
こちらは少しややこしいです。
httptest
パッケージを使用します。
あと自分の場合コントローラーのテストは<package_name>_test
で行わず、<package_name>
のままやっています。
プライベートな関数等を使えたりするので、サイクルインポートにならない限りこうした方が便利です。
package controllers
import (
".../domain"
".../usecase"
"testing"
"net/http"
"net/http/httptest"
"github.com/stretchr/testify/assert"
)
type blogInteractor struct {
usecase.BlogInteractor
}
// mock func
func (i *blogInteractor) DetailBlog(id int) (domain.TBlog, error) {
return domain.TBlog{}, nil
}
func Test_DetailBlogView(t *testing.T) {
con := BlogController{
interactor: &blogInteractor{},
}
table := []struct{
testName string
id int
wantStatus int
} {
{
"success01",
20,
200,
},
}
for _, tt := range table {
t.Run(tt.testName, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "https://abc/def", nil)
qs := r.URL.Query()
qs.Add("id", string(tt.id))
r.URL.RawQuery = qs.Encode()
got := httptest.NewDecoder()
con.DetailBlogView(got, r)
assert.Equal(t, tt.wantStatus, got.Result().StatusCode)
})
}
}
tableをテスト関数の外で定義すればDetailBlogであーだこーだ場合分けを書くことで、非正常もリッチに対応できます。
小技
context
http.RequestのcontextからユーザーのID等を取得するようなコントローラーの処理は沢山あると思います。
そういう際にも
ctx := context.WithValue(
r.Context(),
USER_ID,
userId,
)
r = r.WithContext(ctx)
の用にやってあげればcontextに必要な値を与えた上でテストしてくれます。
このcontextへのセットはテストじゃない部分でも使えるので関数として抜き出しておくべきでしょう。
jsonのポスト
jsonのポストもテストできます。
こんな感じです。
t.Run(tt.testName, func(t *testing.T) {
json_, err := json.Marshal(tt.input)
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodPost, "https://abc/def/post", bytes.NewBuffer(json_))
r = setUserToContext(r, tt.userId)
got := httptest.NewDecoder()
con.DetailBlogView(got, r)
assert.Equal(t, tt.wantStatus, got.Result().StatusCode)
})
まとめ
少し妥協してしまったのでちゃんとした比較になってない点はすいません。
しかし、クリーンアーキテクチャではテストを簡単に、かつ非常にクリーンに書けることが伝わってくれればうれしいです。
全体として assert.NoError
を使わないのはエラーを非正常系も一緒にテストできるようにするためです。
クリーンアーキテクチャのテスト可能
抽象(インターフェース)を参照している(に依存している)ため実装を簡単に差し替えられるためテストが非常に簡単です。
層ごとにテストの目的を明確にしたうえでテストできる
コントローラーやユースケースでは依存先のインターフェースをモックするだけで簡単にテストできる
依存関係が整理されておりテストが本番の処理とは分離されている
Discussion