🙄

フェイクオブジェクトを活用したテストコードの信頼性の改善

2023/01/12に公開約7,200字

はじめに

弊社では、テスト自動化のために、スタブなどを用いたユニットテストと実際のDBなどへ接続するインテグレーションテストを併用していました。

しかし、スタブを用いたユニットテストでは、一部のバグを検出できずにすり抜けてしまう問題に遭遇することがありました。
この問題はより多くのインテグレーションテストを書くことで対処できますが、システムが大きくなるにつれてCIの実行時間が大きく増加してしまうという課題も出てきました。(この課題への対策についても、また別途記事にしたいと考えています)

より現実的な解決策を求めて調査などをした結果、一部のユニットテストをフェイクオブジェクトを用いた手法へ置き換えることにしました。

この記事では、その経験などを元に、フェイクオブジェクトについて解説します。

Goを例に解説しますが、考え方や方法などについては他の言語でも流用可能かと思います。

フェイクオブジェクトって何?

フェイクオブジェクトとは、いわゆるテストダブルの一種で、テストコードなどにおいてあたかも本番向けのオブジェクトかのように振る舞うオブジェクトのことです。

フェイクオブジェクト以外にも、テストダブルにはスタブやモックなど様々な種類がありますが、これらの詳細については下記記事などを参照いただければと思います。

https://ja.wikipedia.org/wiki/テストダブル

https://goyoki.hatenablog.com/entry/20120301/1330608789

フェイクオブジェクトの具体例

説明だけだとイメージがしづらいと思うため、具体例を用いて説明します。

まず、下記のようにinterfaceなどが定義されていたとします。

user/user.go
package user

type Repository interface {
	Add(user *User) error
	Get(id UserID) (*User, error)
}

type User struct {
	ID ID
	Name string
}

func New(id ID, name string) *User {
	return &User{ID: id, Name: name}
}

type ID string
func (id ID) String() string {
	return string(id)
}

var ErrNotFound = errors.New("ユーザーが見つかりません")
var ErrDuplicated = errors.New("ユーザーが重複しています")

以下は本番動作時に使われる想定のRepositoryの実装で、DBへユーザー情報を永続化します。

db/user_repository.go
package db

type dbUserRepository struct {
	db *sql.DB
}

func NewUserRepository(db *sql.DB) user.Repository {
	return &dbUserRepository{db: db}
}

func (r *dbUserRepository) Add(u *user.User) error {
	if _, err := r.db.Exec("INSERT INTO users (id, name) VALUES (?, ?)", u.ID.String(), u.Name); err != nil {
		if isUniqueConstraintViolation(err) {
			return user.ErrDuplicated
		} else {
			return err
		}
	}
	return nil
}

func (r *dbUserRepository) Get(id user.ID) (*user.User, error) {
	var name string
	if err := r.db.QueryRow("SELECT name FROM users WHERE id = ? LIMIT 1", id.String()).Scan(&name); err != nil {
		if err == sql.ErrNoRows {
			return nil, user.ErrNotFound
		} else {
			return nil, err
		}
	}

	return user.New(id, name), nil
}

次に同様のinterfaceを実装したフェイク実装を用意します。

下記コードでは、DBの代わりにインメモリでユーザー情報を管理します。

fake/user_repository.go
package fake

type fakeUserRepository struct {
	userByID map[string]*User
}

func NewUserRepository() user.Repository {
	return &fakeUserRepository{userByID: map[string]*User{}}
}

func (r *fakeUserRepository) Add(u *user.User) error {
	if _, exists := r.userByID[u.ID.String()]; exists {
		return user.ErrDuplicated
	}
	r.userByID[u.ID.String()] = u
	return nil
}

func (r *fakeUserRepository) Get(u *user.User) (*user.User, error) {
	if user, exists := r.userByID[u.String()]; exists {
		return user, nil
	} else {
		return nil, user.ErrNotFound
	}
}

それでは、このフェイク実装を用いてテストを記述していきます。

例えば次のような関数があったとします。

func CreateUser(repository user.Repository, id string, name string) error {
	u := user.New(user.ID(id), name)
	return repository.Add(u)
}

func GetUser(repository user.Repository, id string) (*user.User, error) {
  return repository.Get(user.ID(id))
}

フェイクを活用するためには、これらの関数には引数としてinterfaceであるuser.Repositoryを受け取らせる必要があります。

このようなテクニックは依存性の注入(DI)などと呼ばれます。

https://ja.wikipedia.org/wiki/依存性の注入

こうすることにより、テスト時はフェイク実装をそのまま引数として渡すことができます。

func TestCreateAndGetUser(t *testing.T) {
	repository := fake.NewUserRepository()
	id := "hogepiyo"
	name := "foobar"

	require.NoError(t, CreateUser(repository, id, name))

	user, err := GetUser(repository, user.ID(id))
	require.NoError(t, err)
	assert.Equal(t, name, user.Name)
}

この例のようにフェイクオブジェクトを活用することで、以下のようなメリットが得られます。

  • 実際のDBのデータなどを変更せずに済む
  • 外部のDBや外部サービスなどへの接続を回避できるため、テストを高速で実行できる
  • 自身の制御下にないサービスに依存したコードのテストなどを記述することができる
  • (フェイクの実装コストはかかるものの) 一度用意しておけば、様々な箇所で再利用することができる

フェイクオブジェクトはいつ使うべきか?

最初に述べたように、テストダブルにはここで紹介したフェイクオブジェクトに加えてスタブやモックなど様々なパターンが存在します。

厄介なことに、フェイクオブジェクトを利用できる場面では、スタブやモックなどによっても同様の問題を解決できたりします。

それでは、フェイクオブジェクトはいつ使えば良いのでしょうか?

Googleのソフトウェアエンジニアリング[1]では基本的にフェイクを利用するのが理想的であると説明されています。

本物の実装の利用がテスト内では現実的ではない場合、代わりにフェイクを用いることが最良の選択肢であることが多い。
フェイクは本物の実装同様に振舞うため、他のテストダブルのテクニックより好まれる。
つまり、テスト対象システムにとって、本物の実装とやりとりしているのかフェイクとやりとりしているのか、判別することさえできない状態であるべきだ。[2]

テストダブルと本物のオブジェクトの振る舞いが近ければ近いほどテストコードの信頼性が高まります。

この考えは、例えばフロントエンド開発におけるTesting Libraryの考えにも近いのではないかと思います。

The more your tests resemble the way your software is used, the more confidence they can give you. [3]

ただし、場合によってはフェイクオブジェクトを実装することが難しいケースに遭遇することもあると思います。

そういった場合は、スタブやモックなどの活用も検討すると良いかと思います。

本番向けオブジェクトとフェイクオブジェクトの一貫性を維持する方法について

再びGoogleのソフトウェアエンジニアリング[1:1]からの引用になりますが、フェイクオブジェクトを使うことの問題の一つとして、いかにして本物のオブジェクトとの一貫性を維持するか?というものがあります。

テストダブルを使わなければならない場合、フェイクを使うのがテクニックとして理想的であることが多いが、テストで使わなければならないオブジェクト用のフェイクは存在していないかもしれない。
そしてフェイクを書くのは難しい場合があり、それはフェイクが現在並びに将来において本物の実装同様の挙動を持つことを担保しなければならないためだ。[4]

この問題については、本物のオブジェクトの振る舞いに着目したテストコードを用意し、それを本物のオブジェクトとフェイクオブジェクトの両方で実行することで解決することにしました。

具体例として、以下のような関数を用意します。

repositorytest/user_repository.go
package repositorytest

func UserRepository(t *testing.T, repository user.Repository) {
	user, err := repository.Get(user.ID("abc"))
	require.Error(t, err)
	assert.Nil(t, user)
	assert.ErrorIs(t, err, user.ErrNotFound)

	user = user.New(user.ID("foo"), "bar")
	require.NoError(t, repository.Add(user))

	user, err = repository.Get(user.ID("foo"))
	require.NoError(t, err)
	require.NotNil(t, user)
	assert.Equal(t, user.ID("foo"), user.ID)
	assert.Equal(t, "bar", user.Name)

	err = repository.Add(user.New(user.ID("foo"), "bar"))
	require.Error(t, err)
	assert.ErrorIs(t, err, user.ErrDuplicated)
}

このUserRepositoryは引数としてuser.Repositoryinterfaceを受け取ります。

そのため、この関数を本物のオブジェクトとフェイクオブジェクトのそれぞれのテストコードから利用することができます。

下記は本物のオブジェクトのテストコードで上記の関数を利用する例です。

db/user_repository_test.go
package db

func TestUserRepository(t *testing.T) {
	db := setupDB(t)
	repository := NewUserRepository(db)
	t.Cleanup(func() {
		cleanupDB(t, db)
	})
	repositorytest.UserRepository(t, repository)
}

フェイクオブジェクトのテストでも同様に利用できます。

fake/user_repository_test.go
package fake

func TestUserRepository(t *testing.T) {
	repositorytest.UserRepository(t, NewUserRepository())
}

これにより、フェイク実装と本番実装との間での振る舞いの一貫性をある程度担保できます。

インテグレーションテストについて

フェイクオブジェクトは非常に有用なパターンだと思います。

しかし、フェイクオブジェクトも含むテストダブルはあくまで本物のように振る舞うオブジェクトというだけであって本物のオブジェクトではないため、問題を検出できない可能性というのはどうしても出てきてしまうと思います。

適宜、実際のDBや外部APIなどと連携したインテグレーションテストなどを併用することで、より信頼性が高まるはずです。

チームの文化や使用しているフレームワークなどにもよると思いますが、バランスを見てテストダブルを活用したユニットテストとインテグレーションテストなどを組み合わせることで、より信頼性が高まるはずです。

おわりに

以上、フェイクオブジェクトに関する解説でした。

ユニットテストの信頼性などに関して同じような課題を抱えている方などの助けになれば幸いです。

また、この本で何度か引用しましたが、Googleのソフトウェアエンジニアリング[1:2]はとてもよい本だと思うので、もし興味がありましたらぜひ読んでみてください。

脚注
  1. https://www.amazon.co.jp/Googleのソフトウェアエンジニアリング-―持続可能なプログラミングを支える技術、文化、プロセス-竹辺-靖昭/dp/4873119650 ↩︎ ↩︎ ↩︎

  2. Googleのソフトウェアエンジニアリング 312ページから引用 ↩︎

  3. https://testing-library.com/ より引用 ↩︎

  4. Googleのソフトウェアエンジニアリング 305ページから引用 ↩︎

Discussion

ログインするとコメントできます