🦧

テスト可能 個人開発でクリーンアーキテクチャ...の補足

commits8 min read

前置き

10/10 書き直しました。
table drivenにしました
controllerのテストも追加しました。

こちらの記事の補足記事となります。

https://zenn.dev/maru44/articles/b9e07e91a0ea77

思っている以上に長くなりそうだったので別記事としました。
テスト可能について言及したいと思います。

みなさんは go でテストする際どのようなライブラリを使用していますか?
最近 ginkgo を使用し始めました。
自分の性格を考えると最終的に testing オンリーに回帰しそうです。

今回はとりあえず assert 用に testify を DB のモックとして sqlmock を使っていこうと思います。

参考

table driven test

https://github.com/golang/go/wiki/TableDrivenTests

リポジトリ層のユニットテスト

DB とのやり取りをする実処理の部分をテストします。
MVC で言うと models 層、クリーンアーキテクチャで言うとリポジトリ層になります。

MVC

models/blog.go
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 にも共通します。

まずこちらがテストの対象になるコードです。

interfaces/database/blog_repository.go
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 ハンドラー関数を作成した上で実装していきます。
見てわかるとおりけっこう長くて大変です。

interfaces/database/blog_repository_test.go
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)
        })
    }
}

ユースケース層のテスト

テスト可能で真価が発揮されるのはこちらです。

テストの対象となるのはこちらのコードです。

usecase/blog_interactor.go
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 を使わずに手作りします。とても簡単に作れます。

usecase/blog_interactor_test.go
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>のままやっています。
プライベートな関数等を使えたりするので、サイクルインポートにならない限りこうした方が便利です。

blog_controller_test.go
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 を使わないのはエラーを非正常系も一緒にテストできるようにするためです。

クリーンアーキテクチャのテスト可能

抽象(インターフェース)を参照している(に依存している)ため実装を簡単に差し替えられるためテストが非常に簡単です。

層ごとにテストの目的を明確にしたうえでテストできる
コントローラーやユースケースでは依存先のインターフェースをモックするだけで簡単にテストできる
依存関係が整理されておりテストが本番の処理とは分離されている

GitHubで編集を提案

Discussion

ログインするとコメントできます