本記事でやること
- Testcontainersを使ってElasticsearchに対するテスト環境を構築する
- テスト時に用いるElasticsearchのインデックスを作成するフィクスチャを実装する
- Elasticsearchに検索リクエストを実行するメソッドの単体テストを実装する
対象読者
- Elasticsearchへのアクセス周りの単体テストに興味がある人
- Testcontainersを使ってみたい人
使用言語/ライブラリ
- Go 1.22.2
- go-elasticsearch
- testcontainers-go
背景
データベースに依存した単体テストを実装する際、該当のデータベースの振る舞いを模倣したテストダブルを用いることがよくあります。
ただ、この方法はテストを脆くしたり、偽陽性(本来であれば失敗して欲しいテストが成功してしまうこと)を引き起こす可能性があるため、実際のデータベースを用いてテストを行うことが望ましいとされております。そのため、テスト用のデータベースを用意する必要があり、Dockerを用いてテスト用のデータベースを構築することが多いです。
その際、Dockerfileやdocker-compose.yamlを用意したり、テスト時はdocker-compose up -d
で毎回コンテナを起動する必要があり、手間がかかります。今回は、この手間を解消するために、Testcontainersを使ってElasticsearchに対する単体テストを実装してみようと思います。
実装
今回実装したコードはこちらのリポジトリにあります。
テスト対象となる実装側のコード
Elasticsearchにあるインデックスに対して検索リクエストを実行し結果を返すSearch
メソッドを実装しました。今回はこのSearch
メソッドに対する単体テストを実装します。
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
メソッドでコンテナを停止します。
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
にしています。
// 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のインデックスを作成するフィクスチャを実装します。また、作成したインデックスにはテスト用のドキュメントを登録します。
テストケースによっては、同じインデックス名を使い回すことがあるため、インデックスを作成する際には既存のインデックスを削除するようにします。
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
から読み込んでいます。
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に対する単体テストを実装する際の参考になれば幸いです。