Go言語 + ElasticSearchで単体テストを実行する

:::note info
この記事は、Go Advent Calendar 2022 15日目の投稿です。
:::

https://qiita.com/advent-calendar/2022/go

# 1.はじめに
以前、Go言語+ElasticSearchを用いたプロジェクトに参画していたので、
その時に携わっていた「Go言語」+「ElasticSearch」で実施する単体テスト方法を2つ紹介します。
 -  ElasticSearchサーバーの動作をモックする方法
 -  TestContainersでElasticsearchのインスタンスを起動する方法

前提として、Elastic社がサポートしている公式の[Goクライアント]((https://github.com/elastic/go-elasticsearch))を利用します。
こちらのライブラリについては[この記事](https://qiita.com/shiei_kawa/items/d992f7fdd4c75906ea0b)をご参照ください。

今回の記事のソースコードは、[GitHub](https://github.com/KEMPER0530/go-es-testcode)で公開しています。本記事で紹介するのはテストコードの部分となります。

```:ディレクトリ階層
|--src
|  |--domain
|  |--infrastructure
|  |--interfaces
|  |  |--controllers
|  |  |--elasticsearch
|  |  |  |--test
|  |  |  |  |--shopRepository_1_test.go  # 3.Elasticsearchサーバーの動作をモックするテスト
|  |  |  |  |--shopRepository_2_test.go  # 4.TestContainersでElasticsearchのインスタンスを起動してテスト
|  |--usecase
|  |--main.go
|--config
|  |--elasticsearch
|  |  |--index_settings
|  |  |  |--shop.json           # ElasticSearchのmapping情報
|  |  |--test_data
|  |  |  |--test_data_1.json    # ElasticSearchのレスポンス　３．で利用します
|  |  |  |--test_data_2.json    # BulkInsert用のテストデータ 4.で利用します
|  |--go
|  |--kibana
```


# 2.本記事の対象者
 -  Elasticsearchとやり取りするGoコードの単体テスト方法を知りたい方

# 3.Elasticsearchサーバーの動作をモックする方法
この方法はgomockを利用してテスト中に呼ばれるべき関数とElasticSearchの応答結果を指定して単体テストを実行する方法になります。　

ElasticSearchの応答結果を用意します。`test_data_1.json`が該当します。

```json:レスポンスデータ(長いので途中で割愛)
{
    "took": 14,
    "timed_out": false,
    "_shards": {
        "total": 1,
        "successful": 1,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 2278,
            "relation": "eq"
        },
        "max_score": 9.178341,
        "hits": [{
                "_index": "test_shop",
                "_id": "14018",
                "_score": 9.178341,
                "_ignored": [
                    "kuchikomi.keyword"
                ],
                "_source": {
                "_index": "test_shop",
                "_id": "18007",
                "_score": 9.161789,
                "_source": {
                    "id": 18007,
                    "name": "テストラーメン３",
                    "property": "",
                    "alphabet": "",
                    "name_kana": "てすとらーめん3",
```

テスト対象のコードは下記になります。
```golang:プロダクトコード
func (r *SearchRepository) FindShop(keyword string, area string, name string) (*domain.ShopSearch, error) {
	var buf bytes.Buffer
    // ①ElasticSearchの接続情報を作成する
	e, err := r.EsCon.ConnectElastic(r.EsHost)
	if err := json.NewEncoder(&buf).Encode(r.SearchConditionShop(keyword, area, name)); err != nil {
		log.Println(err)
		return nil, err
	}
    // ②ElasticSearchと接続し、検索を実施する
	res, err := r.EsCon.Search(r.EsIndexShop, buf, e)
	if err != nil {
		log.Printf("Error getting response: %s\n", err)
		return nil, err
	}
　　　　　　　　               ・
　　　　　　　　               ・ 
　　　　　　　　               ・
	return &apiResult, nil
}
```

上記の①②部分をモック化し、テストコードを書いていきます。
モック化の方法はこちらの[記事が参考](https://qiita.com/tenntenn/items/24fc34ec0c31f6474e6d)になります。
```golang
// ①ElasticSearchの接続情報を作成する
e, err := r.EsCon.ConnectElastic(r.EsHost)

// ②ElasticSearchと接続し、検索を実施する
res, err := r.EsCon.Search(r.EsIndexShop, buf, e)
```

作成した正常系のテストコードは下記の通りです。
テスト実施の箇所で用意したレスポンスから値が取得できていることを確認します。
```golang:テストコード
func Test_interfaces_FindShop_MockingServerBehavior(t *testing.T) {

	// 検索するワード
	keyword := "ラーメン"
	area := "東京都"
	name := ""

	// 共通利用するstructを設定
	var r elasticsearch.SearchRepository
	var mockElastic *mock_elasticsearch.MockElastic
	var es *v8.Client

	// gomockの利用設定
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()
	mockElastic = mock_elasticsearch.NewMockElastic(ctrl)

	// ElasticSearchの接続先を設定
	r.EsHost = "dummy-host"
	r.EsIndexShop = "dummy-shop"
	r.EsCon = &infrastructure.ElasticConnection{} // ←は後でmockに差し替える

	es, _ = v8.NewClient(v8.Config{})

	t.Run("【正常系】interfaces:FindShopメソッドのテスト(Elasticsearchサーバーの動作をモックするパターン)", func(t *testing.T) {

		// mockで利用するメソッドの返却値を設定する
		// ConnectElasticメソッドをmock化
		mockElastic.EXPECT().ConnectElastic(r.EsHost).Return(es, nil)

		// テストデータ読み込み
		bytes, err := ioutil.ReadFile("../../../../config/elasticsearch/test_data/test_data_1.json")
		if err != nil {
			panic(err)
		}

		// mockで利用するメソッドの返却値を設定する
		var res esapi.Response
		res.StatusCode = 200
		m := string(bytes)
		res.Body = ioutil.NopCloser(strings.NewReader(m))
		// Searchメソッドをmock化
		mockElastic.EXPECT().Search(r.EsIndexShop, gomock.Any(), es).Return(&res, nil)

		// mock対象メソッドはレシーバーを設定しているのでmock用のレシーバーに差替え
		r.EsCon = mockElastic

		fs, err := r.FindShop(keyword, area, name)

		// テストの実施
		assert.Equal(t, fs.Hits.Hits[0].Source.Id, int64(14018))
		assert.Equal(t, fs.Hits.Hits[0].Source.Name, "テストラーメン")
		assert.Equal(t, fs.Hits.Hits[1].Source.Id, int64(24137))
		assert.Equal(t, fs.Hits.Hits[1].Source.Name, "テストラーメン２")
		assert.Equal(t, fs.Hits.Hits[2].Source.Id, int64(18007))
		assert.Equal(t, fs.Hits.Hits[2].Source.Name, "テストラーメン３")
		assert.Equal(t, err, nil)
	})
```

次にエラー時のテストコードを用意します。
ここではエラー値をgomockの結果にセットし、②のエラー時の動作をテストしています。

```golang:テストコード
	t.Run("【エラー】interfaces:FindShopメソッドのテスト(Elasticsearchサーバーの動作をモックするパターン)", func(t *testing.T) {

		// mockで利用するメソッドの返却値を設定する
		// ConnectElasticメソッドをmock化
		mockElastic.EXPECT().ConnectElastic(r.EsHost).Return(es, nil)

		// mockで利用するメソッドの返却値を設定する
		var res esapi.Response

		// Searchメソッドをmock化
		mockErr := errors.New(fmt.Sprintf("Error: %s", "errors.New"))
		mockElastic.EXPECT().Search(r.EsIndexShop, gomock.Any(), es).Return(&res, mockErr)

		// mock対象メソッドはレシーバーを設定しているのでmock用のレシーバーに差替え
		r.EsCon = mockElastic

		_, err := r.FindShop(keyword, area, name)

		// テストの実施
		assert.Equal(t, err, mockErr)
	})
```
上記テストを実行すると下記の結果が返却されます。
```shell
iMac-3:~/education/gowork/src/github.com/kemper0530/go-es-testcode/src/interfaces/elasticsearch/test (main)$ go test -v shopRepository_1_test.go
=== RUN   Test_interfaces_FindShop_MockingServerBehavior
=== RUN   Test_interfaces_FindShop_MockingServerBehavior/【正常系】interfaces:FindShopメソッドのテスト(Elasticsearchサーバーの動作をモックするパターン)
=== RUN   Test_interfaces_FindShop_MockingServerBehavior/【異常系】interfaces:FindShopメソッドのテスト(Elasticsearchサーバーの動作をモックするパターン)
2022/12/11 16:48:08 Error getting response: Error: errors.New
--- PASS: Test_interfaces_FindShop_MockingServerBehavior (0.00s)
    --- PASS: Test_interfaces_FindShop_MockingServerBehavior/【正常系】interfaces:FindShopメソッドのテスト(Elasticsearchサーバーの動作をモックするパターン) (0.00s)
    --- PASS: Test_interfaces_FindShop_MockingServerBehavior/【異常系】interfaces:FindShopメソッドのテスト(Elasticsearchサーバーの動作をモックするパターン) (0.00s)
PASS
ok      command-line-arguments  0.304s
```
モックを利用する方法は以上となります。

# 4.TestContainersでElasticsearchのインスタンスを起動する方法
こちらは[TestContainers](https://note.com/ryota_0624/n/na51797900aaa)を使ってElasticSearchのコンテナを立ち上げ、データを投入し、ElasticSearchの動作を確認する方法になります。
このテストでは、Elasticsearchにクエリを実行し、作成したクエリが機能するかをテストすることができます。

まずはテスト実施する前にElasticSearchのテストコンテナの準備をします。
```golang:テストコード(環境構築)
func Test_interfaces_FindShop_RunningServer(t *testing.T) {

	// 検索ワードの設定
	keyword := "ラーメン"
	area := "東京"
	name := ""

	// 共通利用するstructを設定
	var r elasticsearch.SearchRepository

	// 環境変数定義
	os.Setenv("ELASTIC_INDEX_SHOP", "test_shop")
	os.Setenv("MAX_CONNS_PER_HOST", "30")
	os.Setenv("RESPONSE_HEADER_TIMEOUT", "30")
	os.Setenv("TIME_OUT", "10")
	os.Setenv("KEEP_ALIVE", "10")

	// ElasticSearchの立ち上げ
	ctx := context.Background()
	elastic, baseUrl, err := initElastic(ctx)
	if err != nil {
		log.Error("Bulk insert failed.")
	}
	os.Setenv("ELASTIC_SEARCH", baseUrl)
	defer elastic.Terminate(ctx)

	// データ投入
	res, _ := fillElasticWithData(baseUrl)
	if res.StatusCode == 400 {
		log.Error("Bulk insert failed.")
	}
```
```golang:テストコード(コンテナの作成)
// ElasticSearchのコンテナ作成 Port:9200でテスト用のElasticSearchコンテナを立ち上げ
func initElastic(ctx context.Context) (testcontainers.Container, string, error) {
	e, err := startEsContainer("9200", "9300")
	if err != nil {
		log.Error("Could not start ES container: " + err.Error())
		return nil, "", err
	}
	ip, err := e.Host(ctx)
	if err != nil {
		log.Error("Could not get host where the container is exposed: " + err.Error())
		return nil, "", err
	}
	port, err := e.MappedPort(ctx, "9200")
	if err != nil {
		log.Error("Could not retrive the mapped port: " + err.Error())
		return nil, "", err
	}
	baseUrl := fmt.Sprintf("http://%s:%s", ip, port.Port())

	// Clientの作成
	cfg := v8.Config{
		Addresses: []string{
			baseUrl,
		},
	}
	es, _ := v8.NewClient(cfg)
	if err != nil {
		log.Fatalf("Error creating the client: %s", err)
		return nil, "", err
	}
	// mapping内容の読み込み
	bytes, err := ioutil.ReadFile("../../../../config/elasticsearch/index_settings/shop.json")
	if err != nil {
		log.Error("Could not read shop.json: " + err.Error())
		return nil, "", err
	}
	mapping := string(bytes)
	// indexの作成
	if err != createIndex(es, mapping) {
		log.Error(err.Error())
		return nil, "", err
	}
	return e, baseUrl, nil
}
```
テストコード内でDockerを立ち上げます。
TestContainersの使い方については[こちら](https://riferrei.com/using-testcontainers-go-with-elasticsearch/)を参照ください。
```golang:テストコード(コンテナ作成)
func startEsContainer(restPort string, nodesPort string) (testcontainers.Container, error) {
	ctx := context.Background()

	rp := fmt.Sprintf("%s:%s/tcp", restPort, restPort)
	np := fmt.Sprintf("%s:%s/tcp", nodesPort, nodesPort)
    // TestContainers生成箇所
	reqes5 := testcontainers.ContainerRequest{
		FromDockerfile: testcontainers.FromDockerfile{
			Context:    "../../../../config/elasticsearch/",
			Dockerfile: "Dockerfile",
		},
		Name:         "es-mock",
		Env:          map[string]string{"discovery.type": "single-node"},
		ExposedPorts: []string{rp, np},
		WaitingFor:   wait.ForLog("started"),
	}
    // TestContainersの実行
	elastic, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: reqes5,
		Started:          true,
	})

	return elastic, err
}
```
ElasticSearchの立ち上げに少し時間がかかるので30秒スリープを入れて、インデックスを作成します。
```golang:テストコード(インデックス作成)
// createIndex indexを作成します
func createIndex(client *v8.Client, mapping string) error {
	req := esapi.IndicesCreateRequest{
		Index: os.Getenv("ELASTIC_INDEX_SHOP"),
		Body:  strings.NewReader(mapping),
	}

	// コンテナ起動後にスリープを実施。ESが起動していないため
	time.Sleep(time.Second * 30)
	// Perform the request with the client.
	res, err := req.Do(context.Background(), client)
	if err != nil {
		log.Fatalf("Error getting response: %s", err)
		return err
	}
	defer res.Body.Close()

	return nil
}
```
jsonからデータを読み込みBulkInsertを実施します。
対象のデータは`test_data_2.json`になります。
```golang:テストコード(データ投入)
// データ投入 BulkInsertでデータを投入する
func fillElasticWithData(baseUrl string) (*http.Response, error) {

	b, err := ioutil.ReadFile("../../../../config/elasticsearch/test_data/test_data_2.json")
	if err != nil {
		panic(err)
	}

	client := http.Client{}
	req, err := http.NewRequest("POST", baseUrl+"/_bulk?pretty", bytes.NewBuffer(b))
	req.Header.Set("Content-Type", "application/x-ndjson")
	res, err := client.Do(req)
	if err != nil {
		log.Error("Could not perform a bulk operation")
	}
	defer res.Body.Close()
	log.Info("Bulk-insert:", res.StatusCode)

	return res, err
}
```

以下、テストコードの部分になります。
```golang:テストコード
	t.Run("【正常系】FindShopメソッドのテスト(DockerコンテナーでElasticsearchの実際のインスタンスを実行)", func(t *testing.T) {

		// ElasticSearchの接続先を設定
		r.EsHost = baseUrl
		r.EsIndexShop = os.Getenv("ELASTIC_INDEX_SHOP")
		r.EsCon = &infra.ElasticConnection{}

		// time.Sleep(time.Second * 300)
		// テスト対象メソッドの呼び出し
		fs, err := r.FindShop(keyword, area, name)

		// テストの実施
		assert.Equal(t, fs.Hits.Hits[0].Source.Id, int64(14018))
		assert.Equal(t, fs.Hits.Hits[0].Source.Name, "テストラーメン")
		assert.Equal(t, fs.Hits.Hits[1].Source.Id, int64(24137))
		assert.Equal(t, fs.Hits.Hits[1].Source.Name, "テストラーメン２")
		assert.Equal(t, fs.Hits.Hits[2].Source.Id, int64(18007))
		assert.Equal(t, fs.Hits.Hits[2].Source.Name, "テストラーメン３")
		assert.Equal(t, err, nil)
	})
```
上記テストを実行すると下記の結果が返却されます。
```
iMac-3:~/education/gowork/src/github.com/kemper0530/go-es-testcode/src/interfaces/elasticsearch/test (main)$ go test -v shopRepository_2_test.go
=== RUN   Test_interfaces_FindShop_RunningServer
2022/12/11 16:01:01 Starting container id: ae0a916aebd5 image: docker.io/testcontainers/ryuk:0.3.3
2022/12/11 16:01:02 Waiting for container id ae0a916aebd5 image: docker.io/testcontainers/ryuk:0.3.3
2022/12/11 16:01:02 Container is ready id: ae0a916aebd5 image: docker.io/testcontainers/ryuk:0.3.3
2022/12/11 16:01:26 Starting container id: aa76682eacb1 image: eceb48ce-3e69-4e95-b78e-44b594d3ef30:bf56ae2c-2033-4b7e-a616-7326ed463163
2022/12/11 16:01:26 Waiting for container id aa76682eacb1 image: eceb48ce-3e69-4e95-b78e-44b594d3ef30:bf56ae2c-2033-4b7e-a616-7326ed463163
2022/12/11 16:01:46 Container is ready id: aa76682eacb1 image: eceb48ce-3e69-4e95-b78e-44b594d3ef30:bf56ae2c-2033-4b7e-a616-7326ed463163
time="2022-12-11T16:02:17+09:00" level=info msg="Bulk-insert:200"
=== RUN   Test_interfaces_FindShop_RunningServer/【正常系】FindShopメソッドのテスト(DockerコンテナーでElasticsearchの実際のインスタンスを実行)
--- PASS: Test_interfaces_FindShop_RunningServer (76.12s)
    --- PASS: Test_interfaces_FindShop_RunningServer/【正常系】FindShopメソッドのテスト(DockerコンテナーでElasticsearchの実際のインスタンスを実行) (0.04s)
PASS
ok      command-line-arguments  (cached)
```
以上、TestContainersを利用した方法となります。

# 5.まとめ
今回、Go言語 + ElasticSearchで単体テストを実行する２つの方法を紹介しました。

どのアプローチを選択するかはテスト目標によって異なりますが、
1つ目はアプリの実際のロジックのテスト、2つ目はElasticsearch間の実際の接続に関するテストに役立つことがわかりました。

# 参考文献
- [Boosting your Tests with Elasticsearch using TestContainers-Go](https://riferrei.com/using-testcontainers-go-with-elasticsearch/)
- [testcontainers-goを使ってテスト時にカジュアルにコンテナを起動する](https://note.com/ryota_0624/n/na51797900aaa)
- [gomockでGoのインターフェースのmockを作成してテストを実行する](https://www.asobou.co.jp/blog/web/gomock)
- [Testing Elasticsearch App In Go](https://medium.com/@ievstrygul/testing-elasticsearch-app-in-go-b32addb1a8d3)
- [How to unit test go code that interacts with Elasticsearch](https://stackoverflow.com/questions/60620495/how-to-unit-test-go-code-that-interacts-with-elasticsearch)