はじめに
Goではinterface
を使うことで機能と実装を分けることができます。
関数やメソッドやがインターフェースにのみ依存するのであれば、テストの時にだけ実装を入れ替えることができ、テストしやすいコードになります。
ここでは、interface
を使って、テストしやすいコード書き、ライブラリなどを使わずにモックを作成し、ユニットテストを書いてみようと思います。
作るもの
2つのユーザIDを受け取り、ユーザの名前が同じかどうか調べるというサービスを作ることにします。
このサービスは以下のインターフェースで表されます。
type UserNameService interface {
IsSameName(id1, id2 UserID) (bool, error)
}
2つのユーザIDを受け取って名前が等しければtrue
を返すというものです。
指定されたIDに対応するユーザが存在しない場合にはerror
が返ってきます。
実装
準備
まずは、User
を定義します。
package sample
type UserID uint
type User struct {
ID UserID
Name string
}
UserNameService
はこのUser
オブジェクトのName
が等しいかを調べるのですが、IsSameName
が引数で受け取るのはUserID
なので、UserID
からUser
を解決しなくてはなりません。
UserNameService
の役割はユーザの名前に関する操作を行うことなので、User
の解決を行うことは単一責任の原則に反します。
そこでUserRepository
を定義して、User
の解決を任せることにします。
UserRepository
の定義は以下のようになります。
type UserRepository interface {
FindByID(UserID) (*User, error)
}
これで、UserNameService
が実装できるようになりました。
UserNameService
UserRepository
を使ったUserNameService
の実装は以下のようになります。
package sample
type UserNameService interface {
IsSameName(id1, id2 UserID) (bool, error)
}
// UserRepositoryImplは UserRepository に依存している
type UserNameServiceImpl struct {
userRepository UserRepository
}
func (impl *UserNameServiceImpl) IsSameName(id1, id2 UserID) (bool, error) {
u1, err := impl.userRepository.FindByID(id1)
if err != nil {
return false, err
}
u2, err := impl.userRepository.FindByID(id2)
if err != nil {
return false, err
}
return (u1.Name == u2.Name), nil
}
func NewUserNameService() UserNameService {
// 依存に実装を注入し、UserNameServiceを構築する
return &UserNameServiceImpl{
NewUserRepository(), // UserRepositoryを注入
}
}
UserNameServiceImpl
がUserNameService
を実装しています。
内部にUserRepository
を持っていることからUserRepositoryImpl
はUserRepository
に依存しているといえます。
IsSameName
はUserRepository
を使ってUser
を検索し、名前が等しいかを調べています。
UserNameService
の実装はこれで終わりですが、このサービス(厳密にはImpl
)はユニットテストのコードが書きやすい状態だといえるでしょう。
その理由として、
- 内部状態がない(
UserRepository
にUser
の管理を任せている)ため、メソッドが呼ばれたときには引数とUserRepository
のみ考えればよい -
UserRepository
はインターフェースなのでテスト時だけ実装を変更できる
ということがあります。
ユニットテストは、その名の通り1つの振る舞いに対するテストなので、他のユニットの影響を受ける状態では書くことが困難になります。(依存先の影響を受けることになる、つまり、依存先のユニットのテストも暗黙的に含んでしまうため。)
テスト時だけ実装を変更できるということは、依存先の動作を自在に制御することができるということです。
これによって、依存先が想定内の動作をしている場合には自身も正しく動作することを保証するテストを書くことができます。
NewUserNameService()
はUserNameService
への依存に対して実装を注入する関数です。
内部で使用しているNewUserRepository()
は同様にUserRepository
への依存に対して実装を注入する関数で、次節で定義します。
テスト時以外で実装を切り替えることはほとんどないと思うので、このようなデフォルトの依存性注入メソッドを定義し他から呼び出されるようにしても問題ないでしょう。
UserRepository
UserRepository
も実装します。
通常ならばKVSやRDBを使ってUser
を検索することになると思いますが、今回はmap[UserID]*User
を使うことにします。
package sample
import (
"errors"
)
type UserRepository interface {
FindByID(UserID) (*User, error)
}
var NoSuchUserError = errors.New("no such user")
// UserRepositoryImplは map[UserID]*User に依存している
type UserRepositoryImpl struct {
m map[UserID]*User
}
func (impl *UserRepositoryImpl) FindByID(id UserID) (*User, error) {
user, ok := impl.m[id]
if !ok {
return nil, NoSuchUserError
}
return user, nil
}
func NewUserRepository() UserRepository {
// 依存に実装を注入し、UserRepositoryを構築する
m := make(map[UserID]*User)
m[1] = &User{1, "tarou"}
m[2] = &User{2, "tarou"}
m[3] = &User{3, "hanako"}
return &UserRepositoryImpl{m}
}
UserRepository
はmap
という実装に依存していますが、ユニットテストが書きやすい状態だといえるでしょう。
なぜなら、map
の実装をテスト時に入れ替えることはできませんが、map
というデータ構造自体が内部状態を制御しやすいため、テスト時に任意の状態を作り出せるからです。
もちろんmap
のような振る舞いをするインターフェースを作ってそれに依存させたほうが柔軟な設計になりますが、今回の場合そこまでする必要はないでしょう。
ちなみに、今回ユニットテストを書きやすいのはmap
を使っているためであって、database/sql
を使いstruct
であるDB
に依存するなどしているとユニットテストを書くのは大変だと思います。
この場合はDB
を包むようなインターフェースを作ることも選択肢に入ります。
NewUserRepository()
ではmap
を注入しUserRepository
を構築しています。
ここでは3つのUser
をハードコーディングしていますが、設定ファイルを通してRDBに接続しデータを読み込むなどしてもよいでしょう。
ユニットテスト
UserRepository
まずはシンプルにテストが書けるUserRepository
から始めます。
package sample
import (
"testing"
)
func TestUserRepository(t *testing.T) {
m := make(map[UserID]*User)
m[1] = &User{1, "tarou"}
repository := &UserRepositoryImpl{
m,
}
if u, _ := repository.FindByID(1); u == nil {
t.Error("FindByID must be successful when the map including a specified key")
}
if _, err := repository.FindByID(2); err != NoSuchUserError {
t.Error("FindByID must be fail with NoSuchUserError when no users have such id")
}
delete(m, 1)
if _, err := repository.FindByID(1); err != NoSuchUserError {
t.Error("FindByID must be fail with NoSuchUserError when a specified user was deleted")
}
}
テストの初めにmap
を作成してIDが1のUser
を追加し、UserRepository
に注入しています。
そして、
-
map
に含まれるユーザIDを指定した場合にはUser
を返す -
map
に含まれないユーザIDを指定した場合にはNoSuchUserError
を返す -
map
からユーザが削除された場合にはNoSuchUserError
を返す
という3つの項目をテストしています。
m[1] = &User{1, "tarou"}
やdelete(m, 1)
のように、メソッドを呼び出したときのmap
の状態を自在に制御できる(m[1]
のような呼び出しの結果を制御できる)ため、FindByID
のロジックのみをテストできています。
モックの作成
UserRepository
はmap
という容易に制御できるデータに依存していたため、テストをシンプルに書くことができました。
しかし、UserNameService
の場合、依存するUserRepository
の内部状態を自在に制御することはできません(UserNameService
からはUserRepository
のインターフェースしか見えないため)。
一方で、UserRepository
はinterface
であるため、モックを作成すればテストにおいて都合のいい振る舞いをさせることが可能になります。
モックの作成にはgomockのようなライブラリを使うこともできますが、自作してもそれほど手間がかからなかったり、モック動作がよくわからない場合の調査が楽だったりすると思うのでライブラリは使わないでおこうと思います。
作成したUserRepository
のモックは以下のようになります。
package sample
type UserRepositoryMock struct {
FindByIDMock func(UserID) (*User, error)
}
func (m *UserRepositoryMock) FindByID(id UserID) (*User, error) {
return m.FindByIDMock(id)
}
モックの作成は、実装するインターフェースのメソッドと同じ型の関数を持つstruct
を作成し、インタフェースの実装ではその関数を呼び出すだけです。
UserNameService
作成したモックを使って書いたUserNameService
のユニットテストは以下のようになります。
package sample
import (
"testing"
)
func TestUserNameService(t *testing.T) {
userRepositoryMock := &UserRepositoryMock{}
service := &UserNameServiceImpl{
userRepositoryMock,
}
count := 0
userRepositoryMock.FindByIDMock = func(id UserID) (*User, error) {
count++
return &User{id, "tarou"}, nil
}
if b, _ := service.IsSameName(1, 2); b != true {
t.Error("IsSameName must be true when users have a same name")
}
if count != 2 {
t.Error("IsSameName must call UserRepository.FindByID just twice")
}
userRepositoryMock.FindByIDMock = func(id UserID) (*User, error) {
if id == 1 {
return &User{1, "tarou"}, nil
} else if id == 2 {
return &User{2, "hanako"}, nil
} else {
return nil, NoSuchUserError
}
}
if b, err := service.IsSameName(1, 2); b != false || err != nil {
t.Error("IsSameName must be false when users have diffferent names")
}
userRepositoryMock.FindByIDMock = func(id UserID) (*User, error) {
if id == 1 {
return &User{1, "tarou"}, nil
} else {
return nil, NoSuchUserError
}
}
if _, err := service.IsSameName(1, 2); err != NoSuchUserError {
t.Error("IsSameName must be fail with NoSuchUserError when some user not found")
}
}
テストの初めにUserRepository
のモックを作成し、UserNameService
に注入しています。
ここでは以下の(3+1)つのテストを行っています。
- 名前が同じユーザの場合には
true
を返す - (一度の
IsSameName
でUserRepository.FindByID
が2回呼ばれる) - ユーザの名前が異なる場合には
false
を返し、かつ、その場合のerror
はnil
である - ユーザが見つからない場合には
NoSuchUserError
を返す
各テストの前にFindByIDMock
に期待する動作を設定しているため、IsSameName
のロジックのみをテストできています。
モックを作成するライブラリでは、メソッドが呼ばれた回数を調べられるようになっていることがありますが、今回の1つめのテストのように外部にカウンタを設置することで実現できます。
毎回func(...){...}
を書かなければならないので冗長な部分もありますが、X.Expect().Method("Y").Return(Z)
のようなライブラリ依存の構文を覚えなくてよいという利点もあります。
今回UserNameService
に依存するメソッドなどは作りませんでしたが、もし依存するものがあればUserRepositoryMock
と同じようにモックを作成してテストを書くことができます。
まとめ
インターフェースと実装を分けることによってユニットテストが書きやすいコードを書けたと思います。
今回書いたコードの設計をパターン化すると以下のようになります。
- 作成したい機能をインタフェースで表現し、実装は
Impl
に書く - 依存先に実装を注入するために
New+インターフェース名
という名前で引数をとらない関数を作る - テスト時に振る舞いを変えられるようにモックを作成する
このようなパターンで設計すればユニットテストの書き方で悩むことが減るのではないでしょうか。