前回はこちら

Chapter1, Chapter2を通じ、Testabilityの高いアーキテクチャへのリファクタリングを行ってきました。 このChapterではTest Doubleを用いてテストの実装をします。 テストを実装することで、3層構造のアーキテクチャが持つ単方向の依存関係の利点などが実感できると思います。

では、まずはTest Doubleについて学びましょう。

Test Doubleについて

Test Doubleとは、テスト対象の依存しているコンポーネントと置き換わり、本物そっくりに振る舞う代役(=double)のことです。 間接入力と間接出力の制御および可視化を実現します。

Test Doubleの種類

Test Doubleにはスタブ、 フェイク、 スパイ、 モック、 ダミーの5つの種類があります。 ここではそれぞれについて簡単に解説します。

スタブ

テスト対象への間接入力を、事前に定義した任意の値に置き換えるTest Doubleです。 依存コンポーネントをスタブに差し替えることで、特定の要件を満たしたテストを実現できます。

Goでの実装例

現在時刻を取得する関数を定義し、テスト時に自分が都合が良い時間を返すように差し替えることができる実装パターンです。

// アプリケーションコード
var now func() time.Time = time.Now
// テストコード
now = func() time.Time {
   return time.Date(2020, time.March, 4, 0, 0, 0, 0, time.UTC)
}

playground

スパイ

テスト対象の間接出力を記録し、後からテストコードでその出力を検証できるようにするTest Doubleです。 以下は http.ResponseWriter インターフェースを満たしている間接出力の記録用の実装である httptest.ResponseRecorder を使って検証している例です。

func main() {
    handler := func(w http.ResponseWriter, r *http.Request) {
        io.WriteString(w, "<html><body>Hello World!</body></html>")
    }

    req := httptest.NewRequest("GET", "http://example.com/foo", nil)
    w := httptest.NewRecorder()
    handler(w, req)

    resp := w.Result()
    body, _ := ioutil.ReadAll(resp.Body)

    fmt.Println(resp.StatusCode)
    fmt.Println(resp.Header.Get("Content-Type"))
    fmt.Println(string(body))
}

playground

モック

事前に間接出力の期待値を設定し、テスト実行時に実際の出力と期待値を検証するTest Doubleです。 スパイではテストコード側で間接出力の検証しますが、モックはモックオブジェクト自身で期待値との検証をする点が異なります。

フェイク

実際のコンポーネントと同等か、それに極めて近い挙動を持つTest Double。

ダミー

テストに影響を与えない代替オブジェクト。 テストには関係ないが、テスト対象の生成時やメソッドのパラメータとしてオブジェクトが必要なときに使用します。 (例: nullオブジェクト)

まとめ

Test Doubleに関して簡単に知識を整理しました。次のStepからは実際にTest Doubleを実装し、テストを書いていきます。 Chapter1に出てきた、『テスト時にこの状態をどれだけ「簡単」に「安定して」作り出せるか』という観点を意識しながらテストを書いていきましょう。 このcodelabではフェイクを除くTest Doubleをライブラリを使わずに実装します。

usecaseのテストを書いていきます。このテストでは、DBアクセスは行わず、 repositoryTest Double に置き換え、中身のロジックに注目してテストを書いていくことにします。

usecase/user_test.goのファイルを開いてみましょう。Createのテストの雛形が書かれています。 ここにテストを実装していきます。

// Userの作成に成功するケース
func TestUser_Create(t *testing.T) {
	t.Skip("TODO: not implemented")
}

Test Doubleの作成

t.Skipの行を削除し、テストを書き始めます。 まずは、テストの対象となる構造体(= User)の初期化を行いましょう。 構造体の初期化はFactory Methodを用意していました。このメソッドを使いましょう。

...
func TestUser_Create(t *testing.T) {
    sut := NewUser(???, nil)
    ...
}

ですが、引数の依存には何を渡せばよいのでしょうか。 第2引数の*sqlx.DBは、本物のDBにアクセスするために使うものなので、今回は必要ありません。 ですので、nilを渡します。( = dummy object )

依存の第1引数のuserRepositoryをTest Doubleに置き換えます。まずはStubとして定義してみましょう。

type userRepositoryStub struct{}

func (s *userRepositoryStub) FindByEmail(ctx context.Context, queryer sqlx.QueryerContext, email string) (*model.User, error) {
	return nil, nil
}

func (s *userRepositoryStub) Create(ctx context.Context, execer sqlx.ExecerContext, m *model.User) error {
	return nil
}

userRepositoryinterfaceを満たすような構造体を定義します。一旦返り値は全てnilで定義しましょう。 このTest Doubleを用いて、初期化を行います。

userUsecase := NewUser(&userRepositoryStub{}, nil)

Create()を呼び出し、errのチェックを行います。 Create()にわたすcontextは、context.Background()を用い、model.Userは適当な値をいれて呼び出してみましょう。

if err := userUsecase.Create(context.Background(), &model.User{
	FirstName:    "test_first_name",
	LastName:     "test_last_name",
	Email:        "test@example.com",
	PasswordHash: "aaa",
}); err != nil {
	t.Fatal(err)
}

usecaseのディレクトリに移動して、go testを呼び出してみます。 (なお、DBのSetupが必要ないので、Docker等を立ち上げる必要がありません)

$ cd internal/usecase
$ go test .

Failしていますね。

% go test . 
--- FAIL: TestUser_Create (0.00s)
    user_test.go:33: すでに登録されています
FAIL
FAIL	github.dena.jp/swet/go-sampleapi/internal/usecase	0.158s
FAIL

Chapter2を思い出すと、FindByEmailは、ErrUserNotExistsをreturnするのが正常系でした。そのようにuserReporitoryStubを修正してみましょう。

func (s *userRepositoryStub) FindByEmail(ctx context.Context, queryer sqlx.QueryerContext, email string) (*model.User, error) {
-	return nil, nil
+	return nil, apierr.ErrUserNotExists
}

もう一度Testを走らせます。

$ go test .
ok  	github.dena.jp/swet/go-sampleapi/internal/usecase	1.848s

今度はPassしています!おめでとうございます、これでStubを使ったTestを通過させることができました!

Test Doubleの改善

とはいえ、このテストでは何を検証しているのかがわかりません。 もう少しStubに手を加え、詳しい検証をできるようにしてみましょう。

以下のコードをcopyしてテストコードに貼り付けてください。

type userRepositoryMock struct{
	findByEmailFn func(ctx context.Context, queryer sqlx.QueryerContext, email string) (*model.User, error)
	createFn func(ctx context.Context, execer sqlx.ExecerContext, m *model.User) error
}

func (s *userRepositoryMock) FindByEmail(ctx context.Context, queryer sqlx.QueryerContext, email string) (*model.User, error) {
	return s.findByEmailFn(ctx, queryer, email)
}

func (s *userRepositoryMock) Create(ctx context.Context, execer sqlx.ExecerContext, m *model.User) error {
	return s.createFn(ctx, execer, m)
}

ややトリッキーな実装ですが、Goで複数のメソッドを持つ構造体をTest doubleに置き換えるときに使う実装パターンの1つです。 構造体のメンバとして、自身のメソッドと同じシグネチャを持つ関数を用意します。

さて、この実装を使って先程のTestを書き直してみます。

func TestUser_Create(t *testing.T) {
	mock  := &userRepositoryMock{
		findByEmailFn: func(ctx context.Context, queryer sqlx.QueryerContext, email string) (user *model.User, err error) {
			return nil, apierr.ErrUserNotExists
		},
		createFn: func(ctx context.Context, execer sqlx.ExecerContext, m *model.User) error {
			return nil
		},
	}
	userUsecase := NewUser(mock, nil)

	if err := userUsecase.Create(context.Background(), &model.User{
		FirstName:    "test_first_name",
		LastName:     "test_last_name",
		Email:        "test@example.com",
		PasswordHash: "aaa",
	}); err != nil {
		t.Fatal(err)
	}
}

先程のTestと比較して、Test Doubleの宣言時ではなく、初期化時にTest Doubleの振る舞いを宣言できるように なっています。 Test Doubleの初期化時に関数を挟み込み、振る舞いをhackするのがポイントですね。

以前の実装と比較すると、この実装パターンにはメリットがいくつかあります。

先ほどのTestを改良し、FindByEmailが、test@example.com (Createに渡している値)の引数で呼ばれているかをチェックする部分を追加してみます。

mock  := &userRepositoryMock{
	findByEmailFn: func(ctx context.Context, queryer sqlx.QueryerContext, email string) (user *model.User, err error) {
		if email != "test@example.com" {
			t.Errorf("email must be test@example.com but %s", email)
		}
		return nil, apierr.ErrUserNotExists
   },
....

このように、関数の内部で、assertionを書くことができます。これでTestを実行してみましょう。

$ go test .
ok  	github.dena.jp/swet/go-sampleapi/internal/usecase	1.848s

問題なくPassするはずです。これでMockとしての役割を果たすこともできるようになりました。

(Optional) Spyを作る

同様の実装パターンを使うことにより、Spyも簡単に作ることができます。 FindByEmailの呼び出しの際のemailを記録し、後で比較をするようなtestを書いてみましょう。

func TestUser_Create(t *testing.T) {
	var passedMail string
	mock  := &userRepositoryMock{
		findByEmailFn: func(ctx context.Context, queryer sqlx.QueryerContext, email string) (user *model.User, err error) {
			passedMail = email
			return nil, app.ErrUserNotExists
		},
		createFn: func(ctx context.Context, execer sqlx.ExecerContext, m *model.User) error {
			return nil
		},
	}
	userUsecase := NewUser(mock, nil)

	if err := userUsecase.Create(context.Background(), &model.User{
		FirstName:    "test_first_name",
		LastName:     "test_last_name",
		Email:        "test@example.com",
		PasswordHash: "aaa",
	}); err != nil {
		t.Fatal(err)
	}

	if passedMail != "test@example.com" {
		t.Errorf("email must be test@example.com but %s", passedMail)
	}
}

とてもシンプルですが、これでSpyとしての役割を果たすことができました。

完了

これで複雑な検証も可能になるようなTest Doubleを実装できました! では、次に色々なケースのTestを実装してみましょう。

// Repositoryからユーザが正しく返ってきたケース
func TestUser_Create_DuplicateEmail(t *testing.T) {
	t.Skip("not implemented")
}

// RepositoryのCreateが失敗したケース
func TestUser_Create_Failed(t *testing.T) {
	t.Skip("not implemented")
}

実装が終わったら

Testを実行し、Passしていることを確認しましょう。

$ go test .
ok  	github.dena.jp/swet/go-sampleapi/internal/usecase	0.022s

Testが実装しおわったら、go testを使ってtest coverageを測定してみましょう。 -coverオプションを付けると、coverageを測定してくれます。

internal/usecaseに移動し、実行してみましょう。

$ go test -cover .
ok  	github.dena.jp/swet/go-sampleapi/internal/usecase	5.633s	coverage: 66.7% of statements

coverageが出力されました。

Line by Lineでcoverageが見たい場合は、-coverprofileオプションを使い、coverageをファイルに書き出します。

$ go test -cover -coverprofile=cover.out .
ok  	github.dena.jp/swet/go-sampleapi/internal/usecase	5.633s	coverage: 66.7% of statements

go tool coverを使うことにより、htmlでcoverageを見ることができます。

$ go tool cover -html=cover.out

すると、browserが開き、以下のような画面が見えるはずです。

coverage

Testが通過した場所は緑の文字で、そうではない場所が赤の文字で表示されます。 無理してCoverage 100%にする必要はないですが、通過してない場所で重要そうな場所があるようでしたら、 これをヒントにTestを追加してみましょう。

E2EのCoverage

Chapter1でE2Eテストを書きましたが、このtestでもcoverageが取得できます。 ですが、go testはデフォルトだと、テスト対象のpackageのcoverageしか取得できません。 e2eのように、testのpackageと、test対象のpackageが分かれている場合は、-coverpkgフラグを設定する必要があります。

applicationのrootディレクトリに行き、以下のコマンドを入力してください。

$ make compose/up/db # test dbの立ち上げ
$ go test -coverpkg=./... -coverprofile=cover.out ./e2e/...
ok  	github.dena.jp/swet/go-sampleapi/internal/usecase	0.496s	coverage: 59.6% of statements in ./...

coverpkgでcoverage取得対象のpackageを指定することにより、coverageの取得ができます。cover.outgo tool coverで開き、coverageを確認してみましょう。

Test戦略

repositoryの層にもテストを書いてみましょう。repositoryのテストでは、DBのアクセスを抽象化するレイヤです。 logic部分はusecaseに切り出したので、repositoryのテストは、そこに書かれているSQLが正しく動作するのか、をチェックするテストを書いてみましょう。 ここでのテストは、大まかに1) DBをTest Doubleにするのか、2) 本物のDBを使うのか、の2つの戦略が考えられます。

ローカル環境やCI環境でTest用のDBを用意するのが困難な場合、Test Doubleに置き換えてTestを行うというのはよく行われているかと思います。 ただ、今回の場合は、MySQLを使っており、dockerを使えば容易にどの環境でもTest用のDBを立ち上げることができ、 また、SQLが正しく動作するのかをDBを使わずにチェックするのは非常に難しいという問題もあり、 2)の戦略をとってTestを書いていきます。

TestのSetUp & CleanUp

repository/user_test.goを開くと、同様にTestの雛形が書かれていると思います。

func TestUser_FindByEmail(t *testing.T) {
	db := sqlx.MustConnect("mysql", app.Config().DBSrc())
	defer func() {
		// DBのCleanup
		db.MustExec("set foreign_key_checks = 0")
		db.MustExec("truncate table users")
		db.MustExec("set foreign_key_checks = 1")
		db.Close()
	}()

    ~ 省略 ~

e2eでも同様の手順を踏んでいますが、最初にTestで使うDBのSetupと、deferにより、testで使ったDBをcleanupしています。

(Go1.14から)

func TestUser_FindByEmail(t *testing.T) {
    db := sqlx.MustConnect("mysql", app.Config().DBSrc())
    t.Cleanup(func() {
	// DBのCleanup
		db.MustExec("set foreign_key_checks = 0")
		db.MustExec("truncate table users")
		db.MustExec("set foreign_key_checks = 1")
		db.Close()
    })

Testの実装

expectという変数名で定義されているmodel.Userのコメントアウトを外し、 TODOコメントのある場所に実装をしていきましょう。

Test Fixturesの投入

DBにfixturesをinsertします。 e2e/user_handler_test.goからcopyしてきて、Valueの部分を書き換えましょう。

	db.MustExec("insert into users(first_name, last_name, email, password_hash) values (?, ?, ?, ?)",
        expect.FirstName, expect.LastName, expect.Email, expect.PasswordHash)

FindByEmailを呼び出す

repositoryを初期化し、メソッドを呼び出しましょう。 第2引数のsqlx.QueryerContextは、Setupで初期化したdbを渡すことができます。

repo := NewUser()
u, err := repo.FindByEmail(context.Background(), db, "test@example.com")
if err != nil {
	t.Fatal(err)
}

GetしたModelとexpectを比較する

FindByEmailの各パラメータが、想定の値(expect)に合致しているかをチェックしましょう。 IDはDBに依存して値が割り振られるので、今回はチェックしません。

if u.Email != expect.Email {
	t.Errorf("email must be %s but %s", expect.Email, u.Email)
}
if u.PasswordHash != expect.PasswordHash {
	t.Errorf("password_hash must be %s but %s", expect.PasswordHash, u.PasswordHash)
}
if u.FirstName != expect.FirstName {
	t.Errorf("first_name must be %s but %s", expect.FirstName, u.FirstName)
}
if u.LastName != expect.LastName {
	t.Errorf("last_name must be %s but %s", expect.LastName, u.LastName)
}

実行

まず、テスト用のDBを立ち上げましょう。アプリケーションのroot directoryに行き、

$ make compose/up/db

と入力してください。その後、repositoryのディレクトリに移動し、

$ go test -v .

と入力してください。

=== RUN   TestUser_FindByEmail
--- PASS: TestUser_FindByEmail (0.03s)
PASS
ok      github.dena.jp/swet/go-sampleapi/internal/repository   0.068s

PASSしていればOKです。

他のケースを実装してみよう

完了

repositoryのテストを実装してみました。 Test Doubleで内部状態を操作するよりも、手間が多く、testの結果の比較にも一工夫必要なケースも多い領域です。 特に、Test Dataのメンテナンスにコストがかかることが多いレイヤなので、事前にチーム内でどこまでテストをやるのか、ということの合意をとっておくと、より効果的だと思います。

これにてcodelabは完了です!

最初は、Testを行うためにはDBにSQLを発行し、想定される状態を作ってからでないとTestが実行できませんでした。 この方法は、DBの構造の変化や、テストの並列化などに弱く、複雑な条件をチェックするのには向いていない方式でした。 このリファクタを通じ、Test Doubleを自由に実装することで、よりロジックに集中したテストの実装が可能になったと思います。 なにか1つでも持ち帰っていただけるものがあればと思っています!

お疲れさまでした!