[Golang]Testcontainersを使ってElasticsearchの単体テストを実装する

## 本記事でやること
- [Testcontainers](https://testcontainers.com/)を使ってElasticsearchに対するテスト環境を構築する
- テスト時に用いるElasticsearchのインデックスを作成するフィクスチャを実装する
- Elasticsearchに検索リクエストを実行するメソッドの単体テストを実装する

## 対象読者
- Elasticsearchへのアクセス周りの単体テストに興味がある人
- Testcontainersを使ってみたい人


## 使用言語/ライブラリ
- Go 1.22.2
- [go-elasticsearch](https://github.com/elastic/go-elasticsearch)
- [testcontainers-go](https://github.com/testcontainers/testcontainers-go)

## 背景
データベースに依存した単体テストを実装する際、該当のデータベースの振る舞いを模倣したテストダブルを用いることがよくあります。

ただ、この方法はテストを脆くしたり、偽陽性（本来であれば失敗して欲しいテストが成功してしまうこと）を引き起こす可能性があるため、実際のデータベースを用いてテストを行うことが望ましいとされております。そのため、テスト用のデータベースを用意する必要があり、Dockerを用いてテスト用のデータベースを構築することが多いです。

その際、Dockerfileやdocker-compose.yamlを用意したり、テスト時は`docker-compose up -d`で毎回コンテナを起動する必要があり、手間がかかります。今回は、この手間を解消するために、Testcontainersを使ってElasticsearchに対する単体テストを実装してみようと思います。


## 実装

今回実装したコードはこちらのリポジトリにあります。

https://github.com/kurakura967/testcontainers-for-elasticsearch


### テスト対象となる実装側のコード
Elasticsearchにあるインデックスに対して検索リクエストを実行し結果を返す`Search`メソッドを実装しました。今回はこの`Search`メソッドに対する単体テストを実装します。

```go:elasticsearch.go
type esClient struct {
    client *elasticsearch.Client
}

func (e *esClient) Search(ctx context.Context, index, query string) ([]byte, error) {
    reqCtx, cancel := context.WithTimeout(ctx, time.Second*1)
    defer cancel()

    req := esapi.SearchRequest{
        Index: []string{index},
        Body:  strings.NewReader(query),
    }

    res, err := req.Do(reqCtx, 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
}
```

### 1. Testcontainersを使ってElasticsearchに対するテスト環境を構築する

TestcontainersによってElasticsearchのコンテナを起動するため、[Elasticsearchのmodule](https://golang.testcontainers.org/modules/elasticsearch/)を利用します。
```bash
go get github.com/testcontainers/testcontainers-go/modules/elasticsearch
```

Elasticsearchのコンテナを起動/停止するためのヘルパー関数を実装します。
[elasticsearch.ElasticsearchContainer](https://github.com/testcontainers/testcontainers-go/blob/main/modules/elasticsearch/elasticsearch.go#L27-L30)をラップした`ElasticsearchContainer`構造体を定義し、`SetupElasticsearch`関数でコンテナを起動し、`Down`メソッドでコンテナを停止します。

```go:helper.go
type ElasticsearchContainer struct {
	*elasticsearch.ElasticsearchContainer
}

func SetupElasticsearch(t *testing.T) *ElasticsearchContainer {
	t.Helper()
	ctx := context.Background()

	container, err := elasticsearch.RunContainer(ctx,
		testcontainers.WithImage("docker.elastic.co/elasticsearch/elasticsearch:8.7.1"),
		elasticsearch.WithPassword("PASSWORD"),
	)
	if err != nil {
		log.Fatalf("failed to start elasticsearch container: %v", err)
	}

	return &ElasticsearchContainer{
		container,
	}
}

func (container *ElasticsearchContainer) Down() {
    ctx := context.Background()
    if err := container.Terminate(ctx); err != nil {
        log.Fatalf("failed to stop elasticsearch container: %v", err)
    }
}
```

`elasticsearch.RunContainer`関数によって起動するElasticsearchクラスタの設定を行います。`testcontainers.WithImage`で使用するイメージを指定し、`elasticsearch.WithPassword`でパスワードを設定します。


また、`elasticsearch.RunContainer`関数の内部では以下のように`discovery.type`を`single-node`、`cluster.routing.allocation.disk.threshold_enabled`(新しいシャードの割り当てを制御する設定)を`false`にしています。

https://github.com/testcontainers/testcontainers-go/blob/main/modules/elasticsearch/elasticsearch.go#L38-L39
```go
// RunContainer creates an instance of the Elasticsearch container type
func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*ElasticsearchContainer, error) {
	req := testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image: fmt.Sprintf("%s:%s", DefaultBaseImage, minimalImageVersion),
			Env: map[string]string{
				"discovery.type": "single-node",
				"cluster.routing.allocation.disk.threshold_enabled": "false",
			},
			...(省略)
```

### 2. テスト時に用いるElasticsearchのインデックスを作成するフィクスチャを実装する
テスト時に用いるElasticsearchのインデックスを作成するフィクスチャを実装します。また、作成したインデックスにはテスト用のドキュメントを登録します。
テストケースによっては、同じインデックス名を使い回すことがあるため、インデックスを作成する際には既存のインデックスを削除するようにします。


```go:fixtures.go
func SetupIndex(repo repository.Elasticsearch, index, mappings string) {
	ctx := context.Background()
	found, err := repo.IsExistsIndex(ctx, index)
	if err != nil {
		panic(err)
	}
	// 既に存在する場合は削除してから作成
	if found {
		err := repo.DeleteIndex(ctx, index)
		if err != nil {
			panic(err)
		}
	}

	err = repo.CreateIndex(ctx, index, mappings)
	if err != nil {
		panic(err)
	}

	for _, doc := range data.DummyDocuments {
		err := repo.InsertDocument(ctx, index, doc)
		if err != nil {
			panic(err)
		}
	}
}
```


### 3. Elasticsearchに検索リクエストを実行するメソッドの単体テストを実装する
1で実装したElasticsearchのコンテナを起動するヘルパー関数を呼び出します。また、テスト終了時にはコンテナを停止するため、`t.Cleanup`関数を使っています。
さらに、個別のテストケースを実行する際に2で実装したインデックスを作成するフィクスチャを呼び出します。また、インデックスを作成する際のマッピング情報は`testdata/mappings.json`から読み込んでいます。
```go:elasticsearch_test.go
func TestSearch(t *testing.T) {
    container := testingHelper.SetupElasticsearch(t)
    t.Cleanup(container.Down)


    config := elasticsearch.Config{
        Addresses:    []string{container.Settings.Address},
        Username:     container.Settings.Username,
        Password:     container.Settings.Password,
        DisableRetry: false,
        CACert:       container.Settings.CACert,
    }
    
    client, err := repository.NewElasticsearch(config)
    if err != nil {
    t.Fatal(err)
    }
	
	...(省略)

	for _, testcase := range testcases {
        ctx := context.Background()
        mappings := testingHelper.LoadFile(t, filepath.Join("testdata", "mappings.json"))
        testingHelper.SetupIndex(client, defaultTestIndex, string(mappings))


        t.Run(testcase.name, func(t *testing.T) {
            ...(省略)
        })
    }
}
```

以下のようにテストを実行するとコンテナが起動しテストが完了、コンテナが停止していることが確認できます。

```bash
$ go test -count=1 -v -run TestSearch ./elasticsearch/elasticsearch_test.go 
=== RUN   TestSearch
2024/05/11 00:27:34 github.com/testcontainers/testcontainers-go - Connected to docker: 
  Server Version: 20.10.7
  API Version: 1.41
  Operating System: Docker Desktop
  Total Memory: 7961 MB
  Resolved Docker Host: unix:///var/run/docker.sock
  Resolved Docker Socket Path: /var/run/docker.sock
  Test SessionID: 5a114182e2c75129cfdcc16b9c2b5bfa3ac06f8ace09c02d1cb2e17e25f54ee1
  Test ProcessID: d875f463-4b69-4fac-a1b1-d4b22eb023d1
2024/05/11 00:27:34 🐳 Creating container for image testcontainers/ryuk:0.7.0
2024/05/11 00:27:34 ✅ Container created: 63a385e183a3
2024/05/11 00:27:34 🐳 Starting container: 63a385e183a3
2024/05/11 00:27:34 ✅ Container started: 63a385e183a3
2024/05/11 00:27:34 🚧 Waiting for container id 63a385e183a3 image: testcontainers/ryuk:0.7.0. Waiting for: &{Port:8080/tcp timeout:<nil> PollInterval:100ms}
2024/05/11 00:27:35 🔔 Container is ready: 63a385e183a3
2024/05/11 00:27:35 🐳 Creating container for image docker.elastic.co/elasticsearch/elasticsearch:8.7.1
2024/05/11 00:27:35 ✅ Container created: 3af53534b0ac
2024/05/11 00:27:35 🐳 Starting container: 3af53534b0ac
2024/05/11 00:27:35 ✅ Container started: 3af53534b0ac
2024/05/11 00:27:35 🚧 Waiting for container id 3af53534b0ac image: docker.elastic.co/elasticsearch/elasticsearch:8.7.1. Waiting for: &{timeout:<nil> Log:.*("message":\s?"started(\s|")?.*|]\sstarted\n) IsRegexp:true Occurrence:1 PollInterval:100ms}
2024/05/11 00:27:55 🔔 Container is ready: 3af53534b0ac
2024/05/11 00:27:55 index test_index not found
=== RUN   TestSearch/正常系(200)
2024/05/11 00:27:56 index test_index is already exits
=== RUN   TestSearch/異常系(400-BadRequest)
2024/05/11 00:27:56 index test_index is already exits
=== RUN   TestSearch/異常系(404-IndexNotFound)
2024/05/11 00:27:57 🐳 Terminating container: 3af53534b0ac
2024/05/11 00:27:57 🚫 Container terminated: 3af53534b0ac
--- PASS: TestSearch (23.47s)
    --- PASS: TestSearch/正常系(200) (0.15s)
    --- PASS: TestSearch/異常系(400-BadRequest) (0.03s)
    --- PASS: TestSearch/異常系(404-IndexNotFound) (0.03s)
PASS
ok  	command-line-arguments	24.165s
```

## まとめ
今回はTestcontainersを使ってElasticsearchに対する単体テストを実装してみました。
コンテナの起動や停止をTestcontainersのコードで管理し、Dockerfileやdocker-compose.yamlを用意する手間を省けるのは大きなメリットのように感じました。
一方、パッケージ単位でのテストを行う場合は、その分コンテナの起動回数が増えるため、テストの実行時間が増加するのではないかという懸念もあります。

この記事がElasticsearchに対する単体テストを実装する際の参考になれば幸いです。