この記事は Voicy Advent Calendar 2020 の 5 日目の記事です。
先日は, @yamagenii さんの 0と1のキーボードでプログラミングしてみた でした。明日は, @saicologic さんの ~ です。
はじめに
自分で開発しているサービスをテストする際には、自前のコードであればInterfaceを用意する様にして、環境に合わせて実際の実装とMockを切り替えてあげれば良いですが、3rdpartyのライブラリのMockを実装しなければいけない場合には、そのライブラリの実装に合わせてMockを実装する必要があります。
そこで今回は、弊社でのサービス実装時に実際に出会ったMockの実装例をまとめてみました。
1. Interfaceが用意されている場合
Interfaceが用意されている場合には、そのInterfaceを満たす構造体を用意し、テストで環境ではMockを代入する様にします。
golangのMockingの基本的な方法です。
//ライブラリのInterface
type VendorInterface interface {
Method(args []string) (string, error)
}
//ライブラリのInterfaceの実装
type VendorImpl struct{}
func (m *VendorImpl) Method(args []string) (string, error) {
return fmt.Sprintf("this is my implements."), nil
}
//Interfaceを満たすモックを実装する
type MockMyImpl struct{}
func (m *MockMyImpl) Method(args []string) (string, error) {
return fmt.Sprintf("I am mocked implements"), nil
}
func New() VendorInterface {
//テスト環境ならMockを返す様にする
if env == "test" {
return &MockMyImpl{}
}
return &VendorImpl{}
}
2. Interfaceが用意されていない場合
Interfaceが用意されていない場合には、こちらでテストしたい関数を満たすInterfaceを用意し、そのInterfaceに依存する様に実装します。
// Interfaceの指定がない場合
type VendorImpl struct{}
func (m *VendorImpl) Method(args []string) (string, error) {
return fmt.Sprintf("this is my implements."), nil
}
// モックしたい関数をもつInterfaceを用意する
type MyInterface interface {
Method(args []string) (string, error)
}
//Interfaceを満たすモックを実装する
type MockMyImpl struct{}
func (m *MockMyImpl) Method(args []string) (string, error) {
return fmt.Sprintf("I am mocked implements"), nil
}
//用意したInterfaceに依存する様にする
func New() MyInterface {
//テスト環境ならMockを返す様にする
if env == "test" {
return &MockMyImpl{}
}
return &VendorImpl{}
}
3. 標準packageでモックできる場合
例えば、net/http
packageのhttp.Client
ではRoundTrip
メソッドを実装するだけでHTTP要求と応答をモックすることができます。
具体的には、net/httpのRoundTripper Interfaceを満たす様に実装すれば良いです。
net/httpのRoundTripperは下記の様になってます。
// RoundTripper is an interface representing the ability to execute a
// single HTTP transaction, obtaining the Response for a given Request.
//
// A RoundTripper must be safe for concurrent use by multiple
// goroutines.
type RoundTripper interface {
// RoundTrip executes a single HTTP transaction, returning
// a Response for the provided Request.
//
// RoundTrip should not attempt to interpret the response. In
// particular, RoundTrip must return err == nil if it obtained
// a response, regardless of the response's HTTP status code.
// A non-nil err should be reserved for failure to obtain a
// response. Similarly, RoundTrip should not attempt to
// handle higher-level protocol details such as redirects,
// authentication, or cookies.
//
// RoundTrip should not modify the request, except for
// consuming and closing the Request's Body. RoundTrip may
// read fields of the request in a separate goroutine. Callers
// should not mutate or reuse the request until the Response's
// Body has been closed.
//
// RoundTrip must always close the body, including on errors,
// but depending on the implementation may do so in a separate
// goroutine even after RoundTrip returns. This means that
// callers wanting to reuse the body for subsequent requests
// must arrange to wait for the Close call before doing so.
//
// The Request's URL and Header fields must be initialized.
RoundTrip(*Request) (*Response, error)
}
3rdpartyのライブラリでは、github.com/elastic/go-elasticsearch/v6
packageなどがRoundTripper
を公開しており、ライブラリのメソッドを直接モックせず、elasticsearch内のTransportの実装を提供してあげれば、http呼び出しをモックすることができます。
以下、elasticsearch
をモックした例です。
最初に、RoundTripperのモックを用意します。
import "net/http"
//MockTransport mocks elasticsearch transport
type MockTransport struct {
// RoundTripから任意のレスポンスを呼び出せる様に保持しておく
Response *http.Response
// RoundTripをモックして、任意のhttpリクエストを指定できる様にする
RoundTripFn func(req *http.Request) (*http.Response, error)
}
// RoundTripをモックして、任意のhttpリクエストを指定できる様にする
func (t *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.RoundTripFn(req)
}
次に、http.ClientのTransportに、テスト環境ではモックしたTransportを指定する様にします。
elasticsearch
では、ConfigでTransportの指定ができる様になっています。
var (
mocktrans MockTransport
)
func NewConfig() elasticsearch.Config {
//テスト環境ならMockを読み込む
if config.GetEnv().IsTest() {
mocktrans = MockTransport{}
//mockのレスポンスを返す様に指定する
mocktrans.RoundTripFn = func(req *http.Request) (*http.Response, error) {
return mocktrans.Response, nil
}
return elasticsearch.Config{
Transport: &mocktrans,
Addresses: []string{elasticSearchURL},
}
}
//本番環境では、ライブラリを読み込む
return elasticsearch.Config{
Addresses: []string{elasticSearchURL},
}
}
// テスケースごとにレスポンスを返るために、Mockを保持しておく
func GetMockTransport() *MockTransport {
return &mocktrans
}
最後に、テストケースごとにレスポンスを指定する様にする
mocktrans := GetMockTransport()
mocktrans.Response = &http.Response{
//StatusCodeを指定する
StatusCode: http.StatusOK,
//Response Bodyをを直接指定する。例えば、Content-Typeがapplication/jsonの場合はjsonを指定
Body: ioutil.NopCloser(bytes.NewReader("{Response Bodyを直接指定する}")),
}
#4. モック箇所がメソッドチェーンになっている場合
Interfaceが用意されておらず、呼び出し箇所がメソッドチェーンになっている場合は、通常のMockではチェーン部分まで実装することができません。
cloud.google.com/go/firestore
packageなどが当てはまります。
その場合には、2通りの方法でモックができます。
1.メソッドチェーン部分をラップする
メソッドチェーンしている箇所を関数でラップし、それに合わせてInterfaceを用意します。
Interfaceを満たすモックを実装します。
// firestoreのメソッドチェーンの呼び出しを関数にまとめる
func (r *repository) Get(ctx context.Context, collectionName, id string) (*MyResponse, error) {
// Firestore検索
doc, err := r.firestoreClient.
Collection(collectionName).
Doc(id).
Get(ctx)
if err != nil {
if status.Code(err) == codes.NotFound {
return nil, nil
}
return nil, xerrors.Errorf("firestore search error. %w", err)
}
// 取得した値をオブジェクトに変換
var data MyResponse
if err := doc.DataTo(&data); err != nil {
return nil, xerrors.Errorf("firestore data bind error. %w", err)
}
return &data, nil
}
//Interfaceを満たす様にMockを実装する
type MockRepository struct {
}
func (r *MockRepository) Get(ctx context.Context, collectionName, id string) (*MyResponse, error) {
return &TestResponce{}, nil
}
2.メソッドチェーンに対応する様にモックする
モックがネストする様に実装してあげます。
メソッドチェーンするメソッドをもつ構造体が全てInterface実装されていれば、そのInterfaceを全て満たす様にそれぞれモックを用意し、モックがネストする様に指定すれば良いです。
Interfaceがない場合には、
- メソッドチェーンで呼び出す構造体全てのInterfaceを実装します。
- そのうち、メソッドの戻り値の構造体は全てInterfaceで返す様に変更します。
- 実装の中で、Interfaceではなく構造体でアクセスしないといけない部分に対応するために、delegateメソッドを用意し、モック内でモック対象のメソッドをもつ構造体を保持します。
- テストケースでは、メソッドチェーンに合わせて事前にモックをネストする様に戻り値を構築し、場合に合わせてモックのdelegateの値を指定してあげます。
実際の実装方法は、長くなるので別記事で書きます。
5. dockernizeされたものがある場合
MySQLやS3などのDBでは、dockernizeされたものがあるので、それらを使ってモックサーバーを立ち上げます。
下記はS3のmockサーバーの例です。S3のモックにはS3互換のオブジェクトストレージのminioを使っています。
ポイントとしては、minio/minio
イメージの中にはクライアント側のmcコマンドが入っていないので、起動時に直接、バケットのディレクトリを作成した上policy.jsonを配置して、デフォルトのバケットを用意しています。
version: "3.7"
services:
minio:
image: minio/minio
volumes:
- minio_data:/data
- minio_config:/root/.minio
- ./minio/export:/export
- ./minio/policies:/policies
ports:
- "9000:9000"
environment:
MINIO_ACCESS_KEY: localtest
MINIO_SECRET_KEY: localtest
entrypoint: sh
command: >
-c "
mkdir -p /data/.minio.sys/mybucket;
cp -r /policies/* /data/.minio.sys/mybucket;
cp -r /export/* /data/;
/usr/bin/minio server /data;
"
networks:
- common-service_backend
networks:
common-service_backend:
external: true
volumes:
minio_data:
minio_config:
終わりに
と言うことで、Mockの実装例Tipsでした。
モックの実装の煩雑さはライブラリごとに様々で、どこまでテストするのかや環境に合わせて最適なものを用意できる様になりたいですね!