0
0

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

Posted at

本記事でやること

  • Testcontainersを使ってElasticsearchに対するテスト環境を構築する
  • テスト時に用いるElasticsearchのインデックスを作成するフィクスチャを実装する
  • Elasticsearchに検索リクエストを実行するメソッドの単体テストを実装する

対象読者

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

使用言語/ライブラリ

背景

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

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

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

実装

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

テスト対象となる実装側のコード

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

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を利用します。

go get github.com/testcontainers/testcontainers-go/modules/elasticsearch

Elasticsearchのコンテナを起動/停止するためのヘルパー関数を実装します。
elasticsearch.ElasticsearchContainerをラップしたElasticsearchContainer構造体を定義し、SetupElasticsearch関数でコンテナを起動し、Downメソッドでコンテナを停止します。

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.typesingle-nodecluster.routing.allocation.disk.threshold_enabled(新しいシャードの割り当てを制御する設定)をfalseにしています。

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

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から読み込んでいます。

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) {
            ...(省略)
        })
    }
}

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

$ 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に対する単体テストを実装する際の参考になれば幸いです。

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