1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Golang]go-elasticsearchでレスポンスをmockする方法

Posted at

本記事でやること

  • go-elasticsearchを使ってElasticsearchからのレスポンスをmockする
  • レスポンスをmockした単体テストを書く
  • 内部実装のコードリーディングを通して何をmockしているのかを理解する

対象読者

使用言語

  • Go 1.21.0

実装

前提

Elasticsearchにあるインデックスに対して検索クエリを実行し結果を返すSearchメソッドを実装しました。
今回は、このSearchメソッドが返すレスポンスをmockします。

Searchメソッドは、検索対象のインデックス名と実行するクエリを文字列として受け取り、検索結果をバイト配列で返します。
また、Searchメソッドはesapi.SearchRequest構造体が持つDoメソッドをラップすることで、Elasticsearchに対して検索リクエストを送信しています。

type esHandler struct {
	client *elasticsearch.Client
}

func NewEsHandler(client *elasticsearch.Client) *esHandler {
	return &esHandler{
		client: client,
	}
}

func (e *esHandler) Search(ctx context.Context, index, query string) ([]byte, error) {
	req := esapi.SearchRequest{
		Index: []string{index},
		Body:  strings.NewReader(query),
	}

	res, err := req.Do(ctx, e.client)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.IsError() {
		var e errResponse
		body, err := io.ReadAll(res.Body)
		if err != nil {
			return nil, fmt.Errorf("faild to read err response body: %w", err)
		}
		if err := json.Unmarshal(body, &e); err != nil {
			return nil, fmt.Errorf("faild to unmarsal err response body: %w", err)
		}
		return nil, fmt.Errorf("failt to search: [%d] %s", e.Status, e.Error.Cause[0].Reason)
	}

	body, err := io.ReadAll(res.Body)
	if err != nil {
		return nil, err
	}

	return body, nil
}

type errResponse struct {
	Status int      `json:"status"`
	Error  errCause `json:"error"`
}

type errCause struct {
	Cause []errReason `json:"root_cause"`
}

type errReason struct {
	Reason string `json:"reason"`
}

mockの実装

簡単なテストコードを通して、Searchメソッドのレスポンスをmockします。
実装は以下の通りです。

mockするために行っていることは単純でelasticsearch.Config構造体のTransportフィールドにhttp.RoundTripper型を満たす構造体(mockTransport)を渡すだけです。
このmockTransport構造体は、RoundTripメソッドを実装しており、http.RoundTripperインターフェースを満たしています。このRoundTripメソッドでは、mockTransport構造体が持つ、http.Responseを返すようにしています。

つまり、http.RoundTripperインターフェースが持つ、RoundTripメソッドが返す値(http.Response型)をmockすることで、Elasitcsearchからのレスポンスをmockすることができます。

type mockTransport struct {
response *http.Response
}

func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return m.response, nil
}

func TestSearch(t *testing.T) {
    body := `{
     "took" : 64,
     "timed_out" : false,
     "_shards" : {
        "total" : 1,
        "successful" : 1,
        "skipped" : 0,
        "failed" : 0
     },
     "hits" : {
        "total" : {
          "value" : 1,
          "relation" : "eq"
        },
        "max_score" : 1.0,
        "hits" : [
          {
            "_index" : "test_index",
            "_id" : "1",
            "_score" : 1.0,
            "_source" : {
              "title" : "hogehoge"
            }
          }
        ]
     }
    }`
	mockTrans := &mockTransport{
	    response: &http.Response{
	        StatusCode: http.StatusOK,
	        Body:       io.NopCloser(strings.NewReader(body)),
	        Header:     http.Header{"X-Elastic-Product": []string{"Elasticsearch"}},
	    },
	}

	cfg := elasticsearch.Config{
	    Transport: mockTrans,
	}

	client, err := elasticsearch.NewClient(cfg)
	if err != nil {
	    t.Fatal(err)
	}
	es := NewEsHandler(client)
	res, err := es.Search(context.Background(), "test", "")
	if err != nil {
        t.Fatal(err)
    }
    var r esResponse
    if err := json.Unmarshal(res, &r); err != nil {
        t.Fatal(err)
    }

    if r.Hits.Hits[0].Source.Title != "hogehoge" {
        t.Errorf("unexpected result: %s", r.Hits.Hits[0].Source.Title)
    }
}

コードリーディング

以下ではgo-elasticsearchライブラリーの内部実装を読み進めていくため、記事の内容が少し長くなります。気になる方だけ読んでください。

なぜRoundTripメソッドをmockすることでElasticsearchのレスポンスをmockできるのかをgo-elasticsearchライブラリーのコードリーディングを通して理解します。

結論

先に結論だけを順序立てて書きます。

  1. Doメソッドの内部で実行されているesapi.TransportインターフェースのPerformメソッドの具象はelasticsearch.BaseClient構造体のPerformメソッドである
  2. elasticsearch.BaseClient構造体が持つPeformメソッドの内部で実行されているelastictransport.InterfaceインターフェースのPerformメソッドの具象はelastictransport.Client構造体のPerformメソッドである
  3. elastictransport.Interfaceelastic-transport-goライブラリーで定義されており、Elasticsearchとデータの送受信を行うためのインターフェースを提供している
  4. elastictransport.Client構造体が持つPerformメソッドの内部で実行されているのはhttp.RoundTripperインターフェースのRoundTripメソッドである
  5. RoundTripメソッドの具象は、ユーザーがelasticsearch.Configで定義したhttp.RoundTripperインターフェースの実装を満たす構造体のRoundTripメソッドである

Doメソッドの内部処理

先で実装したSearchメソッドは、esapi.SearchRequest構造体が持つDoメソッドをラップしています。

まず、Doメソッドの実装を見てみます。以下にDoメソッドの実装を抜粋します。

DoメソッドのL389でレスポンスを受け取り、L400-L404Response構造体に詰め替えていることがわかります。
つまり、L389で実行されているTransport型のPerformメソッドからElasticsearchからのレスポンスが渡って来ていることがわかります。

api.search.go
func (r SearchRequest) Do(providedCtx context.Context, transport Transport) (*Response, error) {

    ...省略
    // Elasticsearchからのレスポンスが渡ってきている
    res, err := transport.Perform(req)

    ...省略
	response := Response{
		StatusCode: res.StatusCode,
		Body:       res.Body,
		Header:     res.Header,
	}

	return &response, nil

Transport型のPerformメソッドの具象

次に、Transport型のPerformメソッドの実装がどこで定義されているのかを探します。
Doメソッドが呼ばれているSearchメソッドに戻ります。以下のコードからDoメソッドのtransport引数(Transport型)にはelasticsearch.Client構造体が渡されていることがわかります。
つまり、elasticsearch.Client構造体がTransportインターフェイスの実装を満たすようにPerformメソッドを持っていることがわかります。

type esHandler struct {
    client *elasticsearch.Client
}

func NewEsHandler(client *elasticsearch.Client) *esHandler {
    return &esHandler{
        client: client,
    }
}

func (e *esHandler) Search(ctx context.Context, index, query string) ([]byte, error) {
    req := esapi.SearchRequest{
        Index: []string{index},
        Body:  strings.NewReader(query),
    }

    res, err := req.Do(ctx, e.client)

elasticsearch.Client構造体が持つPeformメソッドを探します。
まず、elasticsearch.Client構造体が定義されているelasticsearchパッケージのelasticsearch.goをみてみます。
elasticsearch.Client構造体のPerformメソッドは確認できませんでしたが、その代わりBaseClient構造体が持つPerformメソッドを確認することができました。(L322)

以下に実装を抜粋します。

そして、BaseClient構造体はClient構造体に埋め込まれていることがわかります。つまり、Client構造体はBaseClient構造体の埋め込みによってTransportインターフェイスの実装を満たしています。

elasticsearch.go

...省略

// Client represents the Functional Options API.
type Client struct {
	BaseClient
	*esapi.API
}

...省略

// Perform delegates to Transport to execute a request and return a response.
func (c *BaseClient) Perform(req *http.Request) (*http.Response, error) {
    ...省略
	// Retrieve the original request.
	res, err := c.Transport.Perform(req)
	...省略
}

elastictransport.InterfacePerformメソッドの具象

BaseClient構造体のPerformメソッドの中でさらにPeformメソッドが呼ばれていることがわかります。
これはBaseClient構造体のTransportフィールド(elasticsearch.Interface型)が持つ、Performメソッドを呼び出しています。

まず、BaseClient構造体のTransportフィールドがどのように定義されているのかを探すためにBaseClient構造体がイニシャライズされるNewClient関数の実装を見てみます。

NewClient関数のL189で定義されているTransportフィールド(elastictransport.Interface型)の値はnewTransport関数で初期化されています。(L179)

以下にNewClient関数の実装を抜粋します。

elasticsearch.go

...省略

// BaseClient represents the Elasticsearch client.
type BaseClient struct {
	Transport           elastictransport.Interface
	metaHeader          string
	compatibilityHeader bool

	disableMetaHeader   bool
	productCheckMu      sync.RWMutex
	productCheckSuccess bool
}

...省略

func NewClient(cfg Config) (*Client, error) {
	tp, err := newTransport(cfg)
	if err != nil {
		return nil, err
	}

	compatHeaderEnv := os.Getenv(esCompatHeader)
	compatibilityHeader, _ := strconv.ParseBool(compatHeaderEnv)

	client := &Client{
		BaseClient: BaseClient{
			Transport:           tp,
			disableMetaHeader:   cfg.DisableMetaHeader,
			metaHeader:          initMetaHeader(tp),
			compatibilityHeader: cfg.EnableCompatibilityMode || compatibilityHeader,
		},
	}
	client.API = esapi.New(client)

	if cfg.DiscoverNodesOnStart {
		go client.DiscoverNodes()
	}

	return client, nil
}

elastictransport.Interfaceインターフェースはelastic-transport-goライブラリーで定義されています。このライブラリーはREADMEにあるようにgo-elasticsearchで使用されるトランスポートインターフェースを提供しています。
つまり、Elasticsearchとデータの送受信を行うためのインターフェースを提供していることがわかります。

It provides the Transport interface used by go-elasticsearch, connection pool, cluster discovery, and multiple loggers.

newTransport関数の実装に話を戻すと、newTransport関数はElasticsearchとの通信を行うためのHTTPクライアントであるelastictransport.Client構造体を初期化しています。
そして、elastictransport.Client構造体はelastictransport.Interfaceインターフェースの実装を満たしているので、Performメソッドを持っていることがわかります。

以下にnewTransport関数の実装を抜粋します。

elasticsearch.go

...省略

func newTransport(cfg Config) (*elastictransport.Client, error) {
    ...省略
    tp, err := elastictransport.New(tpConfig)
    ...省略
    return tp, nil
}

elastictransport.Client構造体のPerformメソッドの実装を見てみます。

以下にPerformメソッドの実装を抜粋します。

やっとここで、RoundTripメソッドが出てきました。このRoundTripメソッドはelastictransport.Client構造体がもつtransportフィールド(http.RoundTripper型)のRoundTripメソッドを呼び出しています。
また、elastictransport.Client構造体のtransportフィールドは、elastictransport.Config構造体のTransportフィールドの値から渡ってきていることがわかります。

elastictransport.go

func New(cfg Config) (*Client, error) {
    ...省略
    client := Client{
        ...
        transport: cfg.Transport,
        ...
    }
    ...
    return &client, nil
}

// Perform executes the request and returns a response or error.
func (c *Client) Perform(req *http.Request) (*http.Response, error) {

    ...省略
    res, err = c.transport.RoundTrip(req)
    ...省略
    return res, err

elastictransport.Config構造体のTransportフィールドは、NewClinet関数のcfg引数(elasitcsearch.Config型)のTransportフィールドの値から渡ってきています。

ここでやっと、ユーザーが入力したhttp.RoundTripperインターフェースの実装を満たす構造体が影響してくることがわかります。

よって、http.RoundTripperインターフェースの実装を満たす構造体をelasticsearch.Config構造体のTransportフィールドに渡すことで、Elasticsearchとデータの送受信を行うelastictransport.Client構造体のRoundTripメソッドをmockすることができます。
このRoundTripメソッドはHTTP通信の実態を担っているため、このメソッドをmockすることでElasticsearchからのレスポンスをmockすることができるということがわかります。

まとめ

今回は、go-elasticsearchライブラリーを使ってElasticsearchからのレスポンスをmockする方法を紹介しました。
また、内部実装のコードリーディングを通して、なぜRoundTripメソッドをmockすることでレスポンスをmockできるのかを順を追って説明しました。

この記事がgo-elasticsearchの内部実装を理解する手助けになれば幸いです。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?