Goのテストに使える手作りモックパターン

こんにちは。
京都開発拠点でGoエンジニアをしています @yoskeoka です。
Goを中心技術として性能改善やプロダクト間を横断するような機能の設計、実装を行うKTAチーム (京都開発本部 テクニカルアーキテクトチーム) 所属です。

突然ですが、皆さんはGoでテストを書いているでしょうか。
我々はテストを書くことが中長期的なスピードアップに繋がると信じて日々テストを書くようにしています。

KTAではGoの実装をする際にClean Architectureの考えに基づいたpackage分けを行っていますが、packageを分けたり、インターフェースを定義したりとしていくと、テストを書くのが難しい部分というのが出てきます。
そんな場合に使えるモック作りテクニックを今回は紹介したいと思います。

 

Clean Architectureはテストしやすくなると言うが

Clean Architectureを実践するにはDIP: Dependency Inversion Principle (依存性逆転の原則) に従って、ビジネスロジック層はインターフェースに依存するように実装していきます。
そして、インターフェースを通じてデータアクセス層などと繋ぐことで、お互いを疎結合にしておくことができるという実装方法です。
疎結合なので、例えばデータアクセス層でパフォーマンス改善、冪等性の担保などといった修正を加えても、ビジネスロジック層のコード修正が生まれない、分割されているので個別にテスト可能といったメリットがあり、実装するコードが大規模になる程、メリットが活きてきます。

ところで、テスト可能といってもどうやってテストを書くのかというと、大抵の場合はモックを用意すると思います。
ビジネスロジック層のテストをするために、モックを用いずにデータアクセス層をインスタンス化して結合していては、ビジネスロジックのテストをしているのか、データアクセスのテストをしているのか分からなくなってしまいます。

よくありそうなコード例をもとに、モックの書き方と共に、どんな風にテストが書けるようになるのか紹介していきます。

 

テスト対象のコード

よくある感じのインターフェース定義を例にします。
UserとUserGroupがあって、それぞれのデータアクセスを定義しています。

package domain

import "github.com/yoskeoka/go-example/mock/domain/model"

// User represents CRUD operation to User model.
type User interface {
    Read(userID int) (*model.User, error)
    Create(user *model.User) (*model.User, error)
    Update(user *model.User) error
    Delete(userID int) error
}

// UserGroup represents CRUD operation to UserGroup model.
type UserGroup interface {
    Read(grpID int) (*model.UserGroup, error)
    Create(grp *model.UserGroup) (*model.UserGroup, error)
    Update(grp *model.UserGroup) error
    Delete(grpID int) error
    ListUser(grpID int) ([]*model.User, error)
    AddUser(grpID int, userID int) error
    DeleteUser(grpID int, userID int) error
}

アプリケーションにありがちなCRUDを定義しているだけですが、メソッドの数が多くなりがちですよね。
これはmodelに依存していて、modelのコードはこちらです。

package model

// User has user's personal information.
type User struct {
    ID      int
    Name    string
    Address string
}

// UserGroup has user group settings.
type UserGroup struct {
    ID      int
    Name    string
    Private bool
}

そして、これらで実装したビジネスロジック層がこちら。
長くなりますが、リアルな感じをお届けするために全部載せます。

package service

import (
    "errors"
    "fmt"

    "github.com/yoskeoka/go-example/mock/domain"
    "github.com/yoskeoka/go-example/mock/domain/model"
)

// User manages user's personal information.
type User struct {
    userRepo domain.User
    grpRepo  domain.UserGroup
}

// NewUser initializes User service.
func NewUser(
    userRepo domain.User,
    grpRepo domain.UserGroup,
) *User {
    return &User{
        userRepo: userRepo,
        grpRepo:  grpRepo,
    }
}

// Create creates a new user alongside his/her default group.
func (u *User) Create(user *model.User) (*model.User, error) {
    if user.Name == "" {
        return nil, errors.New("user name required")
    }

    createdUser, err := u.userRepo.Create(user)
    if err != nil {
        return nil, err
    }

    createdGrp, err := u.grpRepo.Create(
        &model.UserGroup{
            Name:    fmt.Sprintf("%s's default group", user.Name),
            Private: true,
        },
    )

    err = u.grpRepo.AddUser(createdGrp.ID, createdUser.ID)
    if err != nil {
        return nil, err
    }

    return createdUser, nil
}

本来はこのコードを動かすために、データアクセス層を用意して注入するのですが、テストには不要なので省略します。

 

テストの実装

では、このserviceパッケージのUser.Create()のテストを実装します。
モックを用意するのですが、手作りで簡単に作ることが出来ます。1
インターフェースのモックを用意するときは、本来はメソッドを全て実装したオブジェクトを用意する必要がありますが、Create で実際に参照するのは3つ(domain.User.Create, domain.UserGroup.Create, domain.UserGroup.AddUser)だけで、他の8つのメソッドは関係ないので出来れば実装したくないですよね。

そういう場合はこう書くことが出来ます。

package service_test

import (
    "github.com/yoskeoka/go-example/mock/domain"
)

type userRepoMock struct {
    domain.User
}

type userGrpRepoMock struct {
    domain.UserGroup
}

メソッドの実装が全然ないけど?」と思うと思いますが、
でもこれでメソッドは実装されたことになって、ちゃんとgo test することが出来るんです!

どうしてGoのコンパイラはこんな状態で通してくれるのかというと、それぞれのstructにインターフェースを埋め込んでいるからです。
structに埋め込んだインターフェースは匿名フィールドになっており、そのインターフェースは必要なメソッド定義を全て持っているので、structも必要なメソッド定義を持っている。つまりインターフェースを満たしている!というわけです。
ただ、この匿名のフィールドの値はnil なので、実際にこのモックを使おうとすると、userRepoMock.Create() の呼び出しがされた時点で、

panic: runtime error: invalid memory address or nil pointer dereference

といったエラーが発生するため動きません。
ちゃんと動くようにするには使われるメソッドは最低限、実体がある必要があります。
そこでこの記事で一番伝えたい実装のテクニックで、次のような実装をします。

package service_test

import (
    "github.com/yoskeoka/go-example/mock/domain"
)

type userRepoMock struct {
    domain.User
    FakeCreate func(user *model.User) (*model.User, error)
}

func (m *userRepoMock) Create(user *model.User) (*model.User, error) {
    return m.FakeCreate(user)
}

type userGrpRepoMock struct {
    domain.UserGroup
    FakeCreate  func(grp *model.UserGroup) (*model.UserGroup, error)
    FakeAddUser func(grpID int, userID int) error
}

func (m *userGrpRepoMock) Create(grp *model.UserGroup) (*model.UserGroup, error) {
    return m.FakeCreate(grp)
}

func (m *userGrpRepoMock) AddUser(grpID int, userID int) error {
    return m.FakeAddUser(grpID, userID)
}

今回のテスト対象で使うメソッドだけ、Fake* といった関数のフィールドをstructに追加し、structのメソッドに求められているメソッドを実装しています。その実装内部で、Fake*のものを呼び出すという流れです。
このメソッド定義がインターフェースと違うとダメなので、コピペして作りましょう。
こうして出来たモックを使うと、テストコードはこんな感じで書けるようになります。

package service_test

import (
    "reflect"
    "testing"

    "github.com/yoskeoka/go-example/mock/domain/model"
    "github.com/yoskeoka/go-example/mock/service"
)

func TestUser_Create_1(t *testing.T) {

    userRepo := &userRepoMock{
        FakeCreate: func(user *model.User) (*model.User, error) {
            created := &model.User{ID: 7, Name: user.Name, Address: user.Address}
            return created, nil
        },
    }
    userGrpRepo := &userGrpRepoMock{
        FakeCreate: func(grp *model.UserGroup) (*model.UserGroup, error) {
            created := &model.UserGroup{ID: 9, Name: grp.Name, Private: grp.Private}
            return created, nil
        },
        FakeAddUser: func(grpID int, userID int) error { return nil },
    }

    userSvc := service.NewUser(userRepo, userGrpRepo)

    userInput := model.User{Name: "John", Address: "Kyoto"}
    got, err := userSvc.Create(&userInput)
    if err != nil {
        t.Fatal(err)
    }

    if got.ID != 7 {
        t.Errorf("User.Create() should return model.User.ID = 7, but got = %d", got.ID)
    }

    if got.Name != userInput.Name {
        t.Errorf("User.Create() should return model.User.Name = %s, but got = %s", userInput.Name, got.Name)
    }

    // snip...
}

TableDrivenTestsで書く場合ならこんな感じです。

TableDrivenTestsで書いた場合のコードはここを開く


func TestUser_Create_TableDrivenTests(t *testing.T) { type userFakes struct { Create func(user *model.User) (*model.User, error) } type userGrpFakes struct { Create func(grp *model.UserGroup) (*model.UserGroup, error) AddUser func(grpID int, userID int) error } type args struct { user *model.User } tests := []struct { name string userFakes userFakes userGrpFakes userGrpFakes args args want *model.User wantErr bool }{ {"create successfully", userFakes{ Create: func(user *model.User) (*model.User, error) { created := &model.User{ID: 7, Name: user.Name, Address: user.Address} return created, nil }, }, userGrpFakes{ Create: func(grp *model.UserGroup) (*model.UserGroup, error) { created := &model.UserGroup{ID: 9, Name: grp.Name, Private: grp.Private} return created, nil }, AddUser: func(grpID int, userID int) error { return nil }, }, args{&model.User{Name: "John", Address: "Kyoto"}}, &model.User{ID: 7, Name: "John", Address: "Kyoto"}, false, }, {"create error", userFakes{ Create: func(user *model.User) (*model.User, error) { created := &model.User{ID: 7, Name: user.Name, Address: user.Address} return created, nil }, }, userGrpFakes{ Create: func(grp *model.UserGroup) (*model.UserGroup, error) { created := &model.UserGroup{ID: 9, Name: grp.Name, Private: grp.Private} return created, nil }, AddUser: func(grpID int, userID int) error { return nil }, }, args{&model.User{Name: "John", Address: "Kyoto"}}, &model.User{ID: 7, Name: "John", Address: "Kyoto"}, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { userRepo := &userRepoMock{} userRepo.FakeCreate = tt.userFakes.Create userGrpRepo := &userGrpRepoMock{} userGrpRepo.FakeCreate = tt.userGrpFakes.Create userGrpRepo.FakeAddUser = tt.userGrpFakes.AddUser u := service.NewUser(userRepo, userGrpRepo) got, err := u.Create(tt.args.user) if (err != nil) != tt.wantErr { t.Errorf("User.Create() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("User.Create() = %v, want %v", got, tt.want) } }) } }

このモックの作り方で良いところは、Fake*という関数に、それぞれのテストケースごとに求められる挙動をするような関数を定義してやればいいところです。
正常動作をモックしたい場合は、正常値をreturnするだけの関数を作って差し込めばいいですし、異常動作をモックしたい場合は errorを作ってreturnすればいいです。
呼ばれたことを保証したい場合であれば、何らかのカウンタを用意して、Fake*に差し込む関数から更新すれば大丈夫です。
使い方が単純な分、テストでビジネスロジックで複雑になりがちな例外処理などがきちんと行われているか網羅出来ます。

 

モックライブラリとの比較

Goのモックライブラリと言えば、 gomocktestify/mock を利用した mockery があります。
これらはインターフェースを元にモックコードを生成してくれます。

試しに gomockを使用して作成したモックを使った場合のテストコードを比較してみます。

go install github.com/golang/mock/mockgen@v1.5.0

生成したモックが、テストコードから参照できるようにpackage nameを合わせます。
(ビルドに含まれないように package *_testとなるようにしています)

mockgen --build_flags=--mod=mod \
    -package service_test \
    github.com/yoskeoka/go-example/mock/domain User,UserGroup \
    > service/mock_user_test.go
package service_test

import (
    "reflect"
    "testing"

    "github.com/golang/mock/gomock"
    "github.com/yoskeoka/go-example/mock/domain/model"
    "github.com/yoskeoka/go-example/mock/service"
)

func TestUser_Create_2_mockgen(t *testing.T) {

    ctrl := gomock.NewController(t)

    userRepo := NewMockUser(ctrl)
    userGrpRepo := NewMockUserGroup(ctrl)

    userRepo.EXPECT().
        Create(gomock.Any()).
        DoAndReturn(func(user *model.User) (*model.User, error) {
            return &model.User{ID: 7, Name: user.Name, Address: user.Address}, nil
        }).
        AnyTimes()

    userGrpRepo.EXPECT().
        Create(gomock.Any()).
        DoAndReturn(func(grp *model.UserGroup) (*model.UserGroup, error) {
            return grp, nil
        }).
        AnyTimes()

    userGrpRepo.EXPECT().
        AddUser(gomock.Any(), gomock.Any()).
        DoAndReturn(func(grpID int, userID int) error {
            return nil
        }).
        AnyTimes()

    // ここから下は全く同じコード
    userSvc := service.NewUser(userRepo, userGrpRepo)

    userInput := model.User{Name: "John", Address: "Kyoto"}
    got, err := userSvc.Create(&userInput)
    if err != nil {
        t.Fatal(err)
    }

    if got.ID != 7 {
        t.Errorf("User.Create() should return model.User.ID = 7, but got = %d", got.ID)
    }

    if got.Name != userInput.Name {
        t.Errorf("User.Create() should return model.User.Name = %s, but got = %s", userInput.Name, got.Name)
    }

    // snip...
}

モックを使ったテストコードの見た目は、手作りもgomockも似たような形になります。
gomock はライブラリ化されているものなので、以下のようなことをする機能が提供されています。

  • メリット
    • モックコードの自動生成
    • メソッドが呼ばれた回数、順序でのassertion
    • メソッドの入力値のassertion
  • デメリット
    • モックを使うコードの型がinterface{} になっている ( EXPECT().DoAndReturn)

手作りの場合は反対のような特徴があります。

  • メリット
    • モックを使うコードの型が明確
    • テストコードで共通の挙動や、デフォルトの挙動があれば組み込んで使いまわせる
    • モックに特殊なことを求める場合があれば修正できる
  • デメリット
    • 手作りする必要があるため、モック実装ミスによるエラーがあり得る

gomockは自動生成のメリットはありますが、モックの挙動はテストコード単位でメソッドを一つずつ設定するため、モックの挙動を共通化することは難しいですし、そのようなことはユースケースとして想定されていないと思います。

 

手作りモックの応用

最後に手作りモックだからこそ、簡単に出来ることを紹介します。
Clean Architectureを実践してインターフェースを利用していくと、外側の層のインスタンスを生成するときに多数の依存関係を処理する必要があり、依存関係を解決するための registry を実装するか、wire などのDI用のツールを検討すると思います。
KTAでは registry を実装していて、次のようなコードになっています。

package registry

import "github.com/yoskeoka/go-example/mock/domain"

// ServiceRegistryInterface initializes a service with ensureing dependent on others.
type ServiceRegistryInterface interface {
    User() domain.User
    UserGroup() domain.UserGroup
}

// ServiceRegistry implements service registry interface.
type ServiceRegistry struct {
    // dependencies here...
}

// User returns User service.
func (sr *ServiceRegistry) User() domain.User {
    // initializes User service using it's dependencies.
    return nil
}

// snip...

このコードでは全部省略していますが、様々な依存関係というのは、具体的にいうと外部サービスのClientやDBコネクション、それにservice同士の依存です。

そして、service.NewUser() も実際には以下のように初期化を簡単に行えるようにしています。

 // NewUser initializes User service.
 func NewUser(
-       userRepo domain.User,
-       grpRepo domain.UserGroup,
+       r registry.ServiceRegistryInterface,
 ) *User {
        return &User{
-               userRepo: userRepo,
-               grpRepo:  grpRepo,
+               userRepo: r.User(),
+               grpRepo:  r.UserGroup(),
        }
 }

こうなると、テストでは registryのモックを渡す必要が出てくるのですが、モックのコードも少し追加するだけで対応できます。

package service_test
 import (
        "github.com/yoskeoka/go-example/mock/domain"
        "github.com/yoskeoka/go-example/mock/domain/model"
+       "github.com/yoskeoka/go-example/mock/registry"
 )

+type serviceRegistryMock struct {
+       registry.ServiceRegistryInterface
+       userRepoMock
+       userGrpRepoMock
+}
+
+func (sr *serviceRegistryMock) User() domain.User {
+       return &sr.userRepoMock
+}
+
+func (sr *serviceRegistryMock) UserGroup() domain.UserGroup {
+       return &sr.userGrpRepoMock
+}
+
 type userRepoMock struct {
        domain.User
        FakeCreate func(user *model.User) (*model.User, error)

これを使うと、テストの書き方が少し変わります。

func TestUser_Create_1(t *testing.T) {

    r := &serviceRegistryMock{}
    r.userRepoMock.FakeCreate = func(user *model.User) (*model.User, error) {
        created := &model.User{ID: 7, Name: user.Name, Address: user.Address}
        return created, nil
    }

    r.userGrpRepoMock.FakeCreate = func(grp *model.UserGroup) (*model.UserGroup, error) {
        created := &model.UserGroup{ID: 9, Name: grp.Name, Private: grp.Private}
        return created, nil
    }
    r.userGrpRepoMock.FakeAddUser = func(grpID int, userID int) error { return nil }

    userSvc := service.NewUser(r) // ここより上だけ変わっている

    userInput := model.User{Name: "John", Address: "Kyoto"}
    got, err := userSvc.Create(&userInput)
    if err != nil {
        t.Fatal(err)
    }

    if got.ID != 7 {
        t.Errorf("User.Create() should return model.User.ID = 7, but got = %d", got.ID)
    }

    if got.Name != userInput.Name {
        t.Errorf("User.Create() should return model.User.Name = %s, but got = %s", userInput.Name, got.Name)
    }

    // snip...
}

TableDrivenTestsで書く場合の方が違和感なく変更できると思います。

TableDrivenTestsで書いた場合のコードはここを開く

func TestUser_Create_TableDrivenTests(t *testing.T) {
    type userFakes struct {
        Create func(user *model.User) (*model.User, error)
    }
    type userGrpFakes struct {
        Create  func(grp *model.UserGroup) (*model.UserGroup, error)
        AddUser func(grpID int, userID int) error
    }
    type args struct {
        user *model.User
    }
    tests := []struct {
        name         string
        userFakes    userFakes
        userGrpFakes userGrpFakes
        args         args
        want         *model.User
        wantErr      bool
    }{
        {"create successfully",
            userFakes{
                Create: func(user *model.User) (*model.User, error) {
                    created := &model.User{ID: 7, Name: user.Name, Address: user.Address}
                    return created, nil
                },
            },
            userGrpFakes{
                Create: func(grp *model.UserGroup) (*model.UserGroup, error) {
                    created := &model.UserGroup{ID: 9, Name: grp.Name, Private: grp.Private}
                    return created, nil
                },
                AddUser: func(grpID int, userID int) error {
                    return nil
                },
            },
            args{&model.User{Name: "John", Address: "Kyoto"}},
            &model.User{ID: 7, Name: "John", Address: "Kyoto"},
            false,
        },
        {"create error",
            userFakes{
                Create: func(user *model.User) (*model.User, error) {
                    created := &model.User{ID: 7, Name: user.Name, Address: user.Address}
                    return created, nil
                },
            },
            userGrpFakes{
                Create: func(grp *model.UserGroup) (*model.UserGroup, error) {
                    created := &model.UserGroup{ID: 9, Name: grp.Name, Private: grp.Private}
                    return created, nil
                },
                AddUser: func(grpID int, userID int) error {
                    return nil
                },
            },
            args{&model.User{Name: "John", Address: "Kyoto"}},
            &model.User{ID: 7, Name: "John", Address: "Kyoto"},
            false,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // ---- 変わったのはここの処理だけ ----
            r := &serviceRegistryMock{}
            r.userRepoMock.FakeCreate = tt.userFakes.Create
            r.userGrpRepoMock.FakeCreate = tt.userGrpFakes.Create
            r.userGrpRepoMock.FakeAddUser = tt.userGrpFakes.AddUser
            u := service.NewUser(r)
            // ----- ここまで -----
            got, err := u.Create(tt.args.user)
            if (err != nil) != tt.wantErr {
                t.Errorf("User.Create() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if !reflect.DeepEqual(got, tt.want) {
                t.Errorf("User.Create() = %v, want %v", got, tt.want)
            }
        })
    }
}

これと同じことをgomockでやろうとすると、出来なくはないですが手作りモックのコードを作るよりも手間がかかってきます。

 

まとめ

GoではClean Architectureの実践やそれに近い考えでインターフェースをコードをきれいに保ちつつテスト可能とするためによく使いますし、io.Reader に代表されるインターフェースを活用した抽象化はいたる所で活用されています。
そんなときにテスト用のモックを作る実装パターンを紹介しました。
あなたのコードにも有効活用できそうなら、是非実践してみてください。

 

最後に

2021年も京都開発拠点では引き続き積極的に採用を行っていきます!
新しい技術にチャレンジしたい、技術を手段としてユーザーに価値提供したい、という方はKTAにマッチするのではないかと思います。
ご応募お待ちしております!


マネーフォワード京都開発拠点
中途採用 | 株式会社マネーフォワード京都開発拠点
インターン | 株式会社マネーフォワード京都開発拠点

【サイトのご案内】
マネーフォワード採用サイト
Wantedly
京都開発拠点

【プロダクトのご紹介】
お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

お金の悩みを無料で相談 『マネーフォワード お金の相談』

だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』


  1. Test Double の分類でいうと、Test StubやFake Objectが近いです ↩︎

Pocket