はじめに
NRI 新卒2年目の田中です。Go言語は書き始めて3ヶ月になります。
初めてプログラミング言語を学ぶ際は一通りの機能を知ることになりますが、実際にコードを書いてみないと効力の分からない機能が多くありますよね。
今回は、単体テストにおいてモックを作成する際に役立った機能である埋め込み (Embedding) について紹介します。
1. 埋め込み (Embedding) とは
Go言語の公式ドキュメントの1つである Effective Go - The Go Programming Language の Embeddingの章 では次のように記載されています。
> Go does not provide the typical, type-driven notion of subclassing, but it does have the ability to “borrow” pieces of an implementation by embedding types within a struct or interface.
Go言語では通常の型駆動によるサブクラスの概念は提供されていないものの、構造体やインタフェース型を埋め込むことで実装の一部を「借りる」ことが出来る、というものです。
Effective Go では具体的な実装例として、io.Reader及び io.Writerインタフェースを埋め込んだ io.ReadWriterインタフェースを挙げています。
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// ReadWriterはReaderインタフェースとWriterインタフェースを組み合わせたインタフェースです。
type ReadWriter interface {
Reader
Writer
}
埋め込みと言ってもやることは単純で、実装を借りたいインタフェースや構造体に、その実装を持つインタフェースを記述するのみです。
また、実装を借りたい構造体に構造体を埋め込むこともできます。ただし、インタフェースに構造体を埋め込むパターンは行えません。
ここでは io.Reader及び io.Writerを埋め込むことで、io.ReadWriterインタフェースは io.Readerと io.Writerの出来ることを行えるようになります。
しかしながら、ドキュメントを読んだだけではどういった場面で埋め込みを活用できるのか当時の私はわかりませんでした。
2. 単体テストでの困り事
埋め込みの良さをまだ理解できていないとき、ある日先輩から次のコードの単体テストを実装するように言われました。
type client struct {
redis.UniversalClient
}
func (c *client) Get(ctx context.Context, key string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
cmd := c.UniversalClient.Get(ctx, key)
if err := cmd.Err(); err != nil && err != redis.Nil {
// エラーハンドリング(Redisからデータの取得に失敗)
}
b, err := cmd.Bytes()
if err == redis.Nil {
// エラーハンドリング(Redisに該当するデータが存在しなかった)
}
return b, nil
}
このコードはredisと通信し、特定のキーに格納された値を取得する処理を行っています。また、client構造体に redis.UniversalClientというインタフェースが埋め込まれています。
早速、単体テストの実装を進めていきます。
ここではエラー発生時の挙動をテストするため、(redis.UniversalClient).Getでエラーを発生させたいのですが、どうすればテストできるでしょうか。
ここでは (redis.UniversalClient).Getで redisとの外部通信エラーが発生した際の挙動をテストするために、モックを用いて動作確認する必要があります。
しかし Go言語をはじめて3ヶ月の私は、Go言語でのモックの作り方が今一つ理解できていません。
世の中の事例では gomock を使った例が多く、今回は可能な限り外部パッケージに依存しないプロジェクトでのテストコードの実装であるため、参考にしようにもすることが出来ません。
さて、困りました。どうしましょう。
3. 埋め込みとモックの関係
ここで登場するのが埋め込み (Embedding) です。
client構造体の redis.UniversalClientにモックのクライアントを差し込み、モックに実装をした Getメソッドで意図的にエラーを発生させます。
これにより、redisとの外部通信を行うことなくエラー発生時の挙動を確認し、カバレッジを埋めることが出来ます。
次にどの構造体やインタフェースを埋め込むかを考えます。
テスト対象のメソッドである (redis.UniversalClient).Getは、実際には redis.UniversalClientのインタフェースで定義されていません。redis.UniversalClientに redis.Cmdableのインタフェースが埋め込まれており、redis.Cmdable のメソッドとして定義されています。
そのため以降は (redis.Cmdable).Get と記載していきます。
以下のコードは go-redis における、redis.UniversalClientインタフェースと redis.Cmdableインタフェースの定義になります。
type UniversalClient interface {
Cmdable
AddHook(Hook)
...
Close() error
PoolStats() *PoolStats
}
type Cmdable interface {
Pipeline() Pipeliner
Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error)
...
Get(key string) *StringCmd
...
ReadWrite() *StatusCmd
MemoryUsage(key string, samples ...int) *IntCmd
}
ここでは実装において client構造体に redis.UniversalClientインタフェースが組み込まれているため、redis.UniversalClientを実装したモックのクライアントを組み込んでいきます。
これにより実装を「借りる」ことが可能となります。
type universalClientMock struct {
redis.UniversalClient
}
さて、universalClientMockに redis.UniversalClientを埋め込んだことで universalClientMockは redis.UniversalClientの実装を「借りる」ことが出来ました。
埋め込みを使わない場合、(redis.Cmdable).Getを制御するためには、redis.Cmdableインタフェースの実装を全て満たした構造体を作成する必要があります。しかし、redis.Cmdableには300程度のメソッドが定義されているため、全ての実装を満たすのは現実的ではありません。
つまり埋め込むだけで、300近くある関数の実装を行うことなく redis.UniversalClientとして振る舞うことが出来るのです。
後は制御したいメソッドの実装を進めるのみです。
type universalClientMock struct {
redis.UniversalClient
}
func (m *universalClientMock) Get(ctx context.Context, key string) *redis.StringCmd {
// ここにモックの処理を実装
}
これで一安心、どんなコードに対してもモックを使って単体テストを実装することが出来ます。
そう思っていた時期が私にもありました。
4. 埋め込みで対応できないメソッド
さて、問題が発生します。
この (redis.Cmdable).Getメソッドは、返値が *StringCmd型でありunexportedなfieldのみで定義された構造体なのです。
type StringCmd struct {
baseCmd
val string
}
type baseCmd struct {
ctx context.Context
args []interface{}
err error
keyPos int8
_readTimeout *time.Duration
}
そのためエラー発生時の動作をテストするには、baseCmdに定義されているerrの値を制御する必要があります。
しかし埋め込みとモックを用いても、unexportedなfieldに対して値を注入することは出来ません。
そこで go-redis を丁寧に調べていくと、次のようなテスト用の関数が定義されています。
// NewStringResult関数はvalとerrを初期化したStringCmdを返すテスト用の関数です
func NewStringResult(val string, err error) *StringCmd {
var cmd StringCmd
cmd.val = val
cmd.setErr(err)
return &cmd
}
返値が *StringCmd型であり、制御したいbaseCmdに定義されているerrの値を変更することが出来る関数です。
このようにパッケージによってはご丁寧にテスト用の関数が用意されているので、目的に即した関数を探してあげることも大切です。
この NewStringResult関数を使ってテストコードでのモックの実装を完成させていきましょう。
今回は redis.Nilが発生するパターン、任意のエラーが発生するパターン、エラーが発生しないパターンの計3パターンをモックで出し分けたいため、int型のfieldである getErrorを universalClientMock構造体に持たせています。
const (
other = iota
redisNil
someError
)
type universalClientMock struct {
redis.UniversalClient
getError int
}
func (m *universalClientMock) Get(ctx context.Context, key string) *redis.StringCmd {
switch m.getError {
case redisNil:
return redis.NewStringResult("redis nil", redis.Nil)
case someError:
return redis.NewStringResult("some error", errors.New("some error"))
default:
return redis.NewStringResult("test", nil)
}
}
これにより無事にredisと通信することなく、エラー発生時の挙動を確認することが出来ました。
おわりに
Go言語は機能としては知っていても、実際のコードでその力を100%引き出すことが難しいと感じる言語の一つだと思います。
今回取り上げた埋め込みによるモックの作成は、単体テストにおいて実装の幅を広めてくれます。
外部パッケージを利用してモックを作成するのも良いですが、testing のみを利用した単体テストも試してみてはいかがでしょうか。
参考
- Effective Go
- io(Go Packages)
- gomock(Go Packages)
- gomock(GitHub)
- go-redis(Go Packages)
- go-redis(GitHub)
お問い合わせ
atlax では、ソリューション・サービス全般に関するご相談やお問い合わせを承っております。
関連リンク・トピックス
・OSSサポート・保守サービス | OpenStandia™【NRI】
-
採用情報
NRIの IT基盤サービスでは、キャリア採用を実施しています。様々な職種で募集しておりますので、ご興味を持たれた方は キャリア採用ページも ぜひご覧ください。
※ 記載された会社名 および ロゴ、製品名などは、該当する各社の登録商標または商標です。
※ アマゾン ウェブ サービス、Amazon Web Services、AWS および ロゴは、米国その他の諸国における、Amazon.com, Inc.またはその関連会社の商標です。
※ Microsoft、Azure は、米国 Microsoft Corporation の米国およびその他の国における登録商標または商標です。
※ Google Cloud、Looker、BigQuery および Chromebook は、Google LLC の商標です。
※ Oracle、Java、MySQL および NetSuite は、Oracle Corporation、その子会社および関連会社の米国およびその他の国における登録商標です。NetSuite は、クラウド・コンピューティングの新時代を切り開いたクラウド・カンパニーです。