Chapter1, Chapter2を通じ、Testabilityの高いアーキテクチャへのリファクタリングを行ってきました。 このChapterではTest Doubleを用いてテストの実装をします。 テストを実装することで、3層構造のアーキテクチャが持つ単方向の依存関係の利点などが実感できると思います。
では、まずはTest Doubleについて学びましょう。
Test Doubleとは、テスト対象の依存しているコンポーネントと置き換わり、本物そっくりに振る舞う代役(=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)
}
テスト対象の間接出力を記録し、後からテストコードでその出力を検証できるようにする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))
}
事前に間接出力の期待値を設定し、テスト実行時に実際の出力と期待値を検証するTest Doubleです。 スパイではテストコード側で間接出力の検証しますが、モックはモックオブジェクト自身で期待値との検証をする点が異なります。
実際のコンポーネントと同等か、それに極めて近い挙動を持つTest Double。
テストに影響を与えない代替オブジェクト。 テストには関係ないが、テスト対象の生成時やメソッドのパラメータとしてオブジェクトが必要なときに使用します。 (例: nullオブジェクト)
Test Doubleに関して簡単に知識を整理しました。次のStepからは実際にTest Doubleを実装し、テストを書いていきます。 Chapter1に出てきた、『テスト時にこの状態をどれだけ「簡単」に「安定して」作り出せるか』という観点を意識しながらテストを書いていきましょう。 このcodelabではフェイクを除くTest Doubleをライブラリを使わずに実装します。
usecase
のテストを書いていきます。このテストでは、DBアクセスは行わず、 repository
は Test Double に置き換え、中身のロジックに注目してテストを書いていくことにします。
usecase/user_test.go
のファイルを開いてみましょう。Create
のテストの雛形が書かれています。 ここにテストを実装していきます。
// Userの作成に成功するケース
func TestUser_Create(t *testing.T) {
t.Skip("TODO: not implemented")
}
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
}
userRepository
のinterface
を満たすような構造体を定義します。一旦返り値は全て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を通過させることができました!
とはいえ、このテストでは何を検証しているのかがわかりません。 もう少し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
としての役割を果たすこともできるようになりました。
同様の実装パターンを使うことにより、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")
}
TestUser_Create_DuplicateEmail
のTest Caseでは、usecase.User
のCreate
からreturn
されるerr
の種類もチェックしてみましょう。err
の種類のチェックには、errors.Is
が使えましたね。TestUser_Create_Failed
のTest Case、つまり、insertに失敗するケースは、システムの仕様上は想定していないケースです。想定されていないケースのテストまでを行うかどうかはプロジェクトのテスト方針によるかとは思いますが、Test Doubleを使う練習として、このケースもテストを書いてみましょう。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が開き、以下のような画面が見えるはずです。
Testが通過した場所は緑の文字で、そうではない場所が赤の文字で表示されます。 無理してCoverage 100%にする必要はないですが、通過してない場所で重要そうな場所があるようでしたら、 これをヒントにTestを追加してみましょう。
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.out
をgo tool cover
で開き、coverageを確認してみましょう。
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を書いていきます。
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()
})
expect
という変数名で定義されているmodel.User
のコメントアウトを外し、 TODO
コメントのある場所に実装をしていきましょう。
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)
repository
を初期化し、メソッドを呼び出しましょう。 第2引数のsqlx.QueryerContext
は、Setupで初期化したdb
を渡すことができます。
repo := NewUser()
u, err := repo.FindByEmail(context.Background(), db, "test@example.com")
if err != nil {
t.Fatal(err)
}
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つでも持ち帰っていただけるものがあればと思っています!
お疲れさまでした!