LoginSignup
32
30

More than 5 years have passed since last update.

ユニットテストが書きやすい設計〜自家製モックを添えて〜

Posted at

はじめに

Goではinterfaceを使うことで機能と実装を分けることができます。
関数やメソッドやがインターフェースにのみ依存するのであれば、テストの時にだけ実装を入れ替えることができ、テストしやすいコードになります。

ここでは、interfaceを使って、テストしやすいコード書き、ライブラリなどを使わずにモックを作成し、ユニットテストを書いてみようと思います。

作るもの

2つのユーザIDを受け取り、ユーザの名前が同じかどうか調べるというサービスを作ることにします。

このサービスは以下のインターフェースで表されます。

type UserNameService interface {
    IsSameName(id1, id2 UserID) (bool, error)
}

2つのユーザIDを受け取って名前が等しければtrueを返すというものです。
指定されたIDに対応するユーザが存在しない場合にはerrorが返ってきます。

実装

準備

まずは、Userを定義します。

user.go
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の実装は以下のようになります。

user_name_service.go
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を注入
    }
}

UserNameServiceImplUserNameServiceを実装しています。
内部にUserRepositoryを持っていることからUserRepositoryImplUserRepositoryに依存しているといえます。

IsSameNameUserRepositoryを使ってUserを検索し、名前が等しいかを調べています。

UserNameServiceの実装はこれで終わりですが、このサービス(厳密にはImpl)はユニットテストのコードが書きやすい状態だといえるでしょう。
その理由として、

  • 内部状態がない(UserRepositoryUserの管理を任せている)ため、メソッドが呼ばれたときには引数とUserRepositoryのみ考えればよい
  • UserRepositoryはインターフェースなのでテスト時だけ実装を変更できる

ということがあります。
ユニットテストは、その名の通り1つの振る舞いに対するテストなので、他のユニットの影響を受ける状態では書くことが困難になります。(依存先の影響を受けることになる、つまり、依存先のユニットのテストも暗黙的に含んでしまうため。)
テスト時だけ実装を変更できるということは、依存先の動作を自在に制御することができるということです。
これによって、依存先が想定内の動作をしている場合には自身も正しく動作することを保証するテストを書くことができます。

NewUserNameService()UserNameServiceへの依存に対して実装を注入する関数です。
内部で使用しているNewUserRepository()は同様にUserRepositoryへの依存に対して実装を注入する関数で、次節で定義します。
テスト時以外で実装を切り替えることはほとんどないと思うので、このようなデフォルトの依存性注入メソッドを定義し他から呼び出されるようにしても問題ないでしょう。

UserRepository

UserRepositoryも実装します。
通常ならばKVSやRDBを使ってUserを検索することになると思いますが、今回はmap[UserID]*Userを使うことにします。

user_repository.go
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}
}

UserRepositorymapという実装に依存していますが、ユニットテストが書きやすい状態だといえるでしょう。
なぜなら、mapの実装をテスト時に入れ替えることはできませんが、mapというデータ構造自体が内部状態を制御しやすいため、テスト時に任意の状態を作り出せるからです。
もちろんmapのような振る舞いをするインターフェースを作ってそれに依存させたほうが柔軟な設計になりますが、今回の場合そこまでする必要はないでしょう。

ちなみに、今回ユニットテストを書きやすいのはmapを使っているためであって、database/sqlを使いstructであるDBに依存するなどしているとユニットテストを書くのは大変だと思います。
この場合はDBを包むようなインターフェースを作ることも選択肢に入ります。

NewUserRepository()ではmapを注入しUserRepositoryを構築しています。
ここでは3つのUserをハードコーディングしていますが、設定ファイルを通してRDBに接続しデータを読み込むなどしてもよいでしょう。

ユニットテスト

UserRepository

まずはシンプルにテストが書けるUserRepositoryから始めます。

user_repository_test.go
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のロジックのみをテストできています。

モックの作成

UserRepositorymapという容易に制御できるデータに依存していたため、テストをシンプルに書くことができました。
しかし、UserNameServiceの場合、依存するUserRepositoryの内部状態を自在に制御することはできません(UserNameServiceからはUserRepositoryのインターフェースしか見えないため)。
一方で、UserRepositoryinterfaceであるため、モックを作成すればテストにおいて都合のいい振る舞いをさせることが可能になります。

モックの作成にはgomockのようなライブラリを使うこともできますが、自作してもそれほど手間がかからなかったり、モック動作がよくわからない場合の調査が楽だったりすると思うのでライブラリは使わないでおこうと思います。

作成したUserRepositoryのモックは以下のようになります。

user_repository_mock.go
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のユニットテストは以下のようになります。

user_name_service_test.go
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を返す
  • (一度のIsSameNameUserRepository.FindByIDが2回呼ばれる)
  • ユーザの名前が異なる場合にはfalseを返し、かつ、その場合のerrornilである
  • ユーザが見つからない場合にはNoSuchUserErrorを返す

各テストの前にFindByIDMockに期待する動作を設定しているため、IsSameNameのロジックのみをテストできています。

モックを作成するライブラリでは、メソッドが呼ばれた回数を調べられるようになっていることがありますが、今回の1つめのテストのように外部にカウンタを設置することで実現できます。
毎回func(...){...}を書かなければならないので冗長な部分もありますが、X.Expect().Method("Y").Return(Z)のようなライブラリ依存の構文を覚えなくてよいという利点もあります。

今回UserNameServiceに依存するメソッドなどは作りませんでしたが、もし依存するものがあればUserRepositoryMockと同じようにモックを作成してテストを書くことができます。

まとめ

インターフェースと実装を分けることによってユニットテストが書きやすいコードを書けたと思います。
今回書いたコードの設計をパターン化すると以下のようになります。

  1. 作成したい機能をインタフェースで表現し、実装はImplに書く
  2. 依存先に実装を注入するためにNew+インターフェース名という名前で引数をとらない関数を作る
  3. テスト時に振る舞いを変えられるようにモックを作成する

このようなパターンで設計すればユニットテストの書き方で悩むことが減るのではないでしょうか。

32
30
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
30