Elasticsearchへのアクセスレイヤーの単体テストをmockせず実装する

## 本記事でやること
- Elasticsearchへのアクセスレイヤーに対する単体テストを書く際にmockせず本物のElasticsearchを利用する実装方法の紹介

今回実装したコードは[こちらのレポジトリ](https://github.com/kurakura967/unittest-for-es)で公開しております。

## 対象読者
- Elasticsearchを使ったアプリケーションを実装したことがある方
- Elasticsearch周りの単体テストの書き方に悩んでいる方

## 使用言語とライブラリー
- Go言語 1.18
- [olivere/elastic](https://github.com/olivere/elastic) (非公式 Elasticsearch clientライブラリー)

## 背景
以下、[t_wada](https://twitter.com/t_wada)さんのツイートを拝見し、DBとしてElasticsearchを使っている場合はどのような実装になるのかなとふと疑問に思ったのが、本記事を執筆した背景になります。

テスト時にmockせず本物のElasticsearchを利用するとなると、ローカルのDockerコンテナ上にElasticsearchを立てることになるが、インデックスの作成やドキュメントの追加などセットアップ時の手順がMySQLやSQLServerなどのDBよりも煩雑になるイメージがありました。その点も気になり実際にテストコードを書いてみました。

https://twitter.com/t_wada/status/1595920886926036992


## 実装

### 前提

テストを書く対象のコードとして、以下のようなElasticsearchへ検索クエリを実行しドキュメントの結果を返すというRepository層で定義された`Search`メソッドにスコープを限定します。

`Search`メソッドの引数
   - `keyword`: 検索キーワード
   - `indexName`: (Elasticsearchに登録されている)インデックス名
   


```golang:repository/search.go
package repository

import (
	"context"
	"encoding/json"
	"github.com/kurakura967/unittest-for-es/app/model"
	"github.com/olivere/elastic/v7"
)

type esHandler struct {
	client *elastic.Client
}

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

func (e *esHandler) Search(ctx context.Context, keyword, indexName string) ([]*model.SearchResult, error) {
	termQuery := elastic.NewMatchPhraseQuery("title", keyword)
	res, err := e.client.Search().
		Index(indexName).
		Query(termQuery).
		From(0).Size(10).
		Pretty(true).
		Do(ctx)

	if err != nil {
		return nil, err
	}

	searchArray := make([]*model.SearchResult, 0)

	if res.TotalHits() > 0 {

		for _, hit := range res.Hits.Hits {

			var searchResult model.SearchResult
			if err := json.Unmarshal(hit.Source, &searchResult); err != nil {
				return nil, err
			}
			searchArray = append(searchArray, &searchResult)
		}
	} else {
		return searchArray, nil
	}
	return searchArray, nil
}

```

また、`Search`メソッドは以下モデル層で定義した`SearchResult`構造体を要素に持つ配列を返すようにしています。

```golang:model/search.go
package model

type SearchResult struct {
	Author string `json:"author"`
	Title  string `json:"title"`
}

```
## テストコード

`Search`メソッドに対するテストコードを以下のように実装しました。
こちらは、検索クエリとなるキーワード(`keyword`)と検索を実行するインデックス名(`indexName`)から期待する検索結果が得られるかをテストすることを目的としています。


```golang:repository/search_test.go
package repository_test

import (
	"context"
	"github.com/google/go-cmp/cmp"
	"github.com/kurakura967/unittest-for-es/app/model"
	"github.com/kurakura967/unittest-for-es/app/repository"
	"testing"
)

func TestSearch(t *testing.T) {
	ctx := context.Background()

	tests := []struct {
		testTitle string
		keyword   string
		expected  []*model.SearchResult
	}{
		{
			testTitle: "searchTest1",
			keyword:   "Hamlet",
			expected: []*model.SearchResult{
				{
					Author: "William Shakespeare",
					Title:  "Hamlet",
				},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.testTitle, func(t *testing.T) {
			repo := repository.NewEsHandler(client)
			got, err := repo.Search(ctx, tt.keyword, IndexName)
			if err != nil {
				t.Fatal(err)
			}

			if diff := cmp.Diff(tt.expected, got); diff != "" {
				t.Errorf("SearchResults is unmatched (-want, +got): %s\n", diff)
			}
		})
	}

}

```

もちろんこのままテストを実行しても失敗しますので、テストが実行可能な環境を作成していきます。

```bash
$ go test -v -count=1 ./...
FAIL	github.com/kurakura967/unittest-for-es/app/repository	5.375s

```


## テスト環境の作成

テスト実行時にローカルに立てたElasticsearchに接続し、上記のテストが完了できるように環境を以下の手順で実装していきます。

1. テスト実行時に接続できるElasticsearchをローカルのDockerコンテナに立てる
1. 検索クエリを実行するインデックスの作成や検索結果として返すドキュメントの追加を単体テストを実行する前に行う（セットアップ）
3. テストが完了したら作成したインデックスを削除する（クリーンアップ）

### 1. ElasticsearchをローカルのDockerコンテナに立てる

以下、`docker-compose.test.yaml`を利用しローカルのDockerコンテナでElasticsearchを起動します。
また、今回利用するElasticsearchのVersionは7系(7.16.3)を採用しています。

```yaml:docker-compose.test.yaml
version: "3"

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.16.3
    container_name: elasticsearch
    environment:
      - xpack.security.enabled=false
      - discovery.type=single-node
    ports:
      - "9200:9200"
    ulimits:
      memlock:
        soft: -1
        hard: -1

```

Elasticsearchが無事ローカルで起動することを確認します。

```bash
# Dockerコンテナを起動する
$ docker compose up -d --build

# Elasticsearchのクラスターが無事起動したことを確認
$ curl -X GET "http://localhost:9200/_cluster/health" | jq .
{
  "cluster_name": "docker-cluster",
  "status": "green",
  "timed_out": false,
  "number_of_nodes": 1,
  "number_of_data_nodes": 1,
  "active_primary_shards": 1,
  "active_shards": 1,
  "relocating_shards": 0,
  "initializing_shards": 0,
  "unassigned_shards": 0,
  "delayed_unassigned_shards": 0,
  "number_of_pending_tasks": 0,
  "number_of_in_flight_fetch": 0,
  "task_max_waiting_in_queue_millis": 0,
  "active_shards_percent_as_number": 100
}
```

### 2. セットアップ
`func TestMain(m *testing.M)`を利用しインデックスの作成やドキュメント追加などの共通処置を実装していきます。


以下がセットアップ時に実行する関数になります。実行順に関数毎の簡単な説明を記載します。

- `connectEs()`関数
  - ローカルに起動したElasticsearchに接続する。
      - [デフォルト](https://github.com/olivere/elastic/blob/release-branch.v7/client.go#L33)のエンドポイントは`http://127.0.0.1:9200`になります。

- `setupIndex()`関数
  - インデックスを作成する。
      - settingsやmappingの情報は簡潔にするため最小限にしています。

- `setupDocument()`関数
  - 作成したインデックスにドキュメントを追加する。
      - 今回は簡易的なテストにするため1件のドキュメントのみ追加します。

- `setup()`関数
  - 上記3つの関数をまとめ順に実行していきます。

```golang:main_test.go
package repository_test

import (
	"context"
	"github.com/kurakura967/unittest-for-es/app/model"
	"github.com/olivere/elastic/v7"
	"log"
	"os"
	"strconv"
	"testing"
)

const IndexName = "test_index"

var client *elastic.Client

func connectEs() error {
	var err error
	client, err = elastic.NewClient(elastic.SetSniff(false))
	if err != nil {
		log.Println("failed to connect es")
		return err
	}
	return nil
}

func setupIndex() error {
	mapping := `{
	  "settings": {
		"index": {
		  "refresh_interval": "60s",
		  "auto_expand_replicas": "0-all"
		}
	  },
	  "mappings": {
		"properties": {
		  "author": {
			"type": "text"
		  },
		  "title": {
			"type": "text"
		  }
		}
	  }
	}`

	index, err := client.CreateIndex(IndexName).BodyString(mapping).Do(context.Background())
	if err != nil {
		return err
	}

	if !index.Acknowledged {
		panic("failed to create index")
	}
	return nil
}

func setupDocument() error {
	docs := []*model.SearchResult{
		{
			Author: "William Shakespeare",
			Title:  "Hamlet",
		},
	}

	for i, d := range docs {
		put, err := client.Index().Index(IndexName).OpType("index").Id(strconv.Itoa(i)).BodyJson(d).Do(context.Background())
		if err != nil {
			return err
		}
		log.Printf("Add document to index: %s, type: %s \n", put.Index, put.Type)
	}

	_, err := client.Refresh(IndexName).Do(context.Background())
	if err != nil {
		return err
	}

	return nil
}

func setup() (err error) {
	err = connectEs()
	if err != nil {
		return err
	}
	err = setupIndex()
	if err != nil {
		return err
	}
	err = setupDocument()
	if err != nil {
		return err
	}
	return nil
}

func TestMain(m *testing.M) {
	err := setup()
	if err != nil {
		os.Exit(1)
	}
	defer cleanup()
	m.Run()
}

```

### 3. クリーンアップ

Elasticsearchには同名のインデックスは登録できないので、テストを実行した後に作成したインデックスを削除するクリーンアップ処理を実装し、Dockerコンテナを起動したままテストを複数回実行できるようにします。

- `deleteIndex()`関数
  - インデックスを削除する。

- `cleanup()`関数
  - インデックスの削除とElasticsearchへの内部接続を閉じる

```golang:main_test.go

const IndexName = "test_index"

...省略

func cleanup() {
	deleteIndex()
	client.Stop()
}

func deleteIndex() error {
	deleteIndex, err := client.DeleteIndex(IndexName).Do(context.Background())
	if err != nil {
		return err
	}
	if !deleteIndex.Acknowledged {
		panic("failed to delete index")
	}
	log.Println("deleted index")
	return nil
}

func TestMain(m *testing.M) {
	err := setup()
	if err != nil {
		os.Exit(1)
	}
	defer cleanup()
	m.Run()
}
```

## テストの実行

ElasticsearchをローカルのDockerコンテナ上に起動した状態で再度テストを実行します。

```bash
$ export COMPOSE_FILE=docker-compose.test.yaml
$ docker compose up -d --build
$ go test -v -count=1 ./...
...
2022/12/16 15:27:50 Add document to index: test_index, type: _doc 
=== RUN   TestSearch
=== RUN   TestSearch/searchTest1
--- PASS: TestSearch (0.01s)
    --- PASS: TestSearch/searchTest1 (0.01s)
PASS
2022/12/16 15:27:50 deleted index
ok  	github.com/kurakura967/unittest-for-es/app/repository	0.560s
...
```

無事テストが通ったことを確認することができました。:v:


## おまけ: mockした場合のテストコード

DBへアクセスするRepository層をmockし上位のService層のテストをするというのがよく見かけるパターンなので、今回の実装を例として書いてみたいと思います。


まずは、テスト対象となるService層の実装を以下に記載します。

```golang: search_searvice.go
package service

import (
	"context"
	"github.com/kurakura967/unittest-for-es/app/model"
	"github.com/kurakura967/unittest-for-es/app/repository"
	"log"
)

type SearchService struct {
	repo repository.Searcher
}

func NewSearchService(repo repository.Searcher) *SearchService {
	return &SearchService{repo: repo}
}

func (s *SearchService) GetSearchService(ctx context.Context, keyword, indexName string) (sr []*model.SearchResult, err error) {
	res, err := s.repo.Search(ctx, keyword, indexName)
	if err != nil {
		log.Println(err)
	}
    // 何某かの処理
    // 今回は簡単にするためそのまま返す
	return res, nil
}


```

上記で実装したRepository層の`Search`メソッドは`Searcher`インターフェースに実装したとします。


```golang
package repository

import (
	"context"
	"github.com/kurakura967/unittest-for-es/app/model"
)

type Searcher interface {
	Search(ctx context.Context, keyword, indexName string) ([]*model.SearchResult, error)
}


```

### テストコード

Repository層の`Search`メソッドを以下のようにmockし特定の値を持った構造体が変えるようにします。（この点が自作自演と言われる点でしょうか）

```golang:testdata/mock.go
package testdata

import (
	"context"
	"github.com/kurakura967/unittest-for-es/app/model"
)

type EsMockHandler struct{}

func (e EsMockHandler) Search(ctx context.Context, keyword, indexName string) ([]*model.SearchResult, error) {
	return []*model.SearchResult{
		{
			Author: "William Shakespeare",
			Title:  "Hamlet",
		},
	}, nil
}

```

Service層のテストを以下のように実装しました。今回はRepository層の`Search`メソッドから返ってきた構造をそのままService層が返すということをしているので、テストデータがmockしたデータと一致しています。

```golang:search_service_test.go
package service_test

import (
	"context"
	"github.com/google/go-cmp/cmp"
	"github.com/kurakura967/unittest-for-es/app/model"
	"github.com/kurakura967/unittest-for-es/app/service"
	"github.com/kurakura967/unittest-for-es/app/service/testdata"
	"testing"
)

func TestSearchService(t *testing.T) {
	ctx := context.Background()
	ser := service.NewSearchService(testdata.EsMockHandler{})

	tests := []struct {
		testTitle string
		keyword   string
		expected  []*model.SearchResult
	}{
		{
			testTitle: "searchServiceTest1",
			keyword:   "Hamlet",
			expected: []*model.SearchResult{
				{
					Author: "William Shakespeare",
					Title:  "Hamlet",
				},
			},
		},
	}
	for _, tt := range tests {
		got, err := ser.GetSearchService(ctx, tt.keyword, "test_index")
		if err != nil {
			t.Fatal(err)
		}

		if diff := cmp.Diff(tt.expected, got); diff != "" {
			t.Errorf("SearchResults is unmatched (-want, +got): %s\n", diff)
		}
	}

}


```

無事テストが通ったことを確認できました。

```bash
$ go test -v -count=1 ./...
....
=== RUN   TestSearchService
--- PASS: TestSearchService (0.00s)
PASS
ok  	github.com/kurakura967/unittest-for-es/app/service	0.418s
```