インターフェースとテスト


はじめに

筆者はかつてインターフェースを理解するのに時間がかかった記憶がある。まずそもそもインターフェースがなくても動作するプログラムを作れる。それにインターフェースに列挙された関数に実装がないから実体を掴みづらい。またこういった理由でインターフェースを利用していない人や、そもそも存在を知らない人も多いのではないかと思う。

インターフェースに関するできるだけ噛み砕いた説明と、テスト時のインターフェースを用いるメリットについて記載する。

インターフェースとは

インターフェースとは「約束事が書かれた仮面」

「仮面」とはどういうことか?誰に何を「約束」しているのだろうか?

インターフェースそのものはデータや具体的な実装などの実体を持たない。ただ単に機能が列挙されているだけ。実際にデータや実装を持つのは、その機能を実装した作業者、図中の作業者Aや作業者Bだ。
一方インターフェース利用者からはインターフェースの奥の具体的なデータや実装を見ることはできない。というより、見なくていいし意識しなくていい。
この全体像が「仮面」と表現した理由だ。作業者AとBはインターフェースという「仮面」をかぶっていて、利用者はその仮面の奥を意識しない。

では、何を「約束」しているのだろうか?
インターフェースには機能が一覧が列挙されていて、利用者に対してその仮面をかぶった具体的な作業者はこれらの機能を必ず有していることを約束している。作業者からの観点では、仮面をかぶる以上はこれらの機能を必ず実装しなければいけない。

キャッシュの例

例としてキャッシュを挙げてみる。

キャッシュの主な機能は、取得(Get)、格納(Set)、削除(Del)である。ということで、CacheContextという仮面(インターフェース)を用意し機能一覧としてGet、Set、Delを列挙しておく。この仮面をかぶって実際に作業を行う作業者がRedisCache、MemcachedCache、実際にそれぞれのキャッシュに接続してGet、Set、Delを行う。
利用者は作業者がRedisに繋ぐのかmemcachedに繋ぐのか意識しなくて良い。仮面に書かれている通り、Get、Set、Delが必ず用意されていると知っているだけで十分なのである。

なぜわざわざインターフェースを使う!?

「わざわざインターフェースを用いる必要あるのか?」「利用者で作業者A、Bを切り替えて直接つなげばいいじゃないか」と思われる方もいらっしゃるかもしれない。
インターフェースを用いるメリットとしていくつか挙げられるが、その中の大きな一つとしてテストのしやすさがある。インターフェースを用いることによって単体テストを実装しやすくなる、テスト対象だけにテストをフォーカスできるというメリットがある。

インターフェースとテスト

インターフェースを理解したところでインターフェースの有無によるテストを比較してみる。あるシナリオを用意し、インターフェースを用いない例と用いる例を用意して比較してみよう。

シナリオ

プログラムの流れとしては、プログラムA(本番用プログラム)はStudentCacheAdaptorを通じて学生情報(ID、氏名、Email)をRedisキャッシュにGet、Set、Delする。インターフェース利用者はStudentCacheAdaptorで、作業者はRedisCacheとなる。
このシナリオにおいて、テスト対象はStudentCacheAdaptor、テストしたいことは期待したkeyをRedisCacheのGet、Set、Delに渡せているかどうか。

インターフェースを使わずに実装した場合

まずはインターフェースを利用せず利用者StudentCacheAdaptorと作業者RedisCacheを直接つないでみる。テストプログラムを用意しStudentCacheAdaptorをテストしようとするとどうなるだろうか。

直接繋いでしまうと、テストプログラムでもRedisCache内でRedisに接続する必要がある。StudentCacheAdaptorが正しいkeyをRedisCacheに渡しているかどうかをテストしたいだけなのに、Redisを起動し接続する必要がありその過程で失敗する可能性がある。これはテストの本質とは全く関係ない。
またチームで実装していたとして、StudentCacheAdaptorとRedisCacheの実装者が異なる場合、RedisCacheの実装が済むまでStudentCacheAdaptorのテストができない。

では、「RedisCacheに並ぶようにテスト用に作業者を用意すればいい」と思われる方もいらっしゃるかもしれない。
そうなるとStudentCacheAdaptor内で本番用とテスト用を切り替える作業が必要となる。StudentCacheAdaptorは本番で使用しようとしているコードなのに、そこに本番とテストの切り替え作業が生じてしまい余計なコードが必要になったりバグのリスクを生む。

インターフェースを使用して実装した場合

ではインターフェースを使用してみよう。どういう恩恵を受けられるか。

StudentCacheAdaptorとRedisCacheの間にCacheContextというインターフェースを用意する。そしてCacheContextMockというテスト用の作業者を用意する。

このような構成にしておくと、CacheContextMock内ではRedisに繋ぐ必要がなく、StudentCacheAdaptorから受け取ったkeyが想定通りか否かチャックするだけでよくテストしたいことにフォーカスできる。またチームで実装していたとしても、RedisCacheの実装を待たずCacheContextMockを作るだけで良い。
加えて、インターフェースを用意しておくと、StudentCacheAdaptorからはCacheContextという仮面しか見えておらずその先を意識する必要がない。そのためStudentCacheAdaptorの中で、RedisCacheやCacheContextMockへの切り替え作業を実装しなくて良い。

例のようにテスト用の作業者を一般に「モック」と呼ぶ。

おわりに

インターフェースを使用すると、ポリモーフィズムを実現できたり、ここで示したようにテストを実装しやすくしたりするメリットがある。コード量は増えるがうまく使えば運用コストを下げられる。
この記事を通して「インターフェースがなんとなく理解できた」「インターフェースを使ってみよう」と思っていただけると幸いである。

実装例

これまでに記載したRedisの例を、実際のコード(Go言語)を記載するので参考にしてほしい。

プログラムA
func ProgramA() {
	// 本番用となるプログラムAは、RedisCacheを用意する
	cc := RedisCache{}

	adp := newStudentCacheAdaptor(cc)

	getName := adp.GetName()
	fmt.Printf("name = '%s'\n", getName)

	adp.SetName("taro")

	adp.DelName()
}
テストプログラム
func TestStudentCacheAdaptor(t *testing.T) {
	t.Run("StudentCacheAdaptorのテスト", func(t *testing.T) {
		// テスト用は、CacheContextMockを用意する
		cc := CacheContextMock{t: t}

		adp := newStudentCacheAdaptor(cc)

		adp.GetName()
		adp.SetName("hanako")
		adp.DelName()
	})
}
StudentCacheAdaptor
type StudentCacheAdaptor struct {
	cc CacheContext
}

func (adp StudentCacheAdaptor) GetName() string {
	key := "name"
	return adp.cc.Get(key)
}

func (adp StudentCacheAdaptor) SetName(val string) {
	key := "name"
	adp.cc.Set(key, val)
}

func (adp StudentCacheAdaptor) DelName() {
	key := "name"
	adp.cc.Del(key)
}
CacheContext
type CacheContext interface {
	Get(key string) string
	Set(key, val string)
	Del(key string)
}
RedisCache
type RedisCache struct {
}

func (r RedisCache) Get(key string) string {
	conn := connect()
	return conn.Get(key)
}

func (r RedisCache) Set(key, val string) {
	conn := connect()
	conn.Set(key, val)
}

func (r RedisCache) Del(key string) {
	conn := connect()
	conn.Del(key)
}
CacheContextMock
type CacheContextMock struct {
	t *testing.T
}

func (m CacheContextMock) Get(key string) string {
	// key が正しいかチェック
	if key != "name" {
		m.t.Errorf("'name' is expected key but actual is %s", key)
	}

	return "" // key をテストするだけなので、返り値に意味はない
}

func (m CacheContextMock) Set(key, val string) {
	// key が正しいかチェック
	if key != "name" {
		m.t.Errorf("'name' is expected key but actual is %s", key)
	}
}

func (m CacheContextMock) Del(key string) {
	// key が正しいかチェック
	if key != "name" {
		m.t.Errorf("'name' is expected key but actual is %s", key)
	}
}
newStudentCacheAdaptor
func newStudentCacheAdaptor(cc CacheContext) StudentCacheAdaptor {
	return StudentCacheAdaptor{cc: cc}
}