この記事は、Go Advent Calendar 2022 15日目の投稿です。
1.はじめに
以前、Go言語+ElasticSearchを用いたプロジェクトに参画していたので、
その時に携わっていた「Go言語」+「ElasticSearch」で実施する単体テスト方法を2つ紹介します。
- ElasticSearchサーバーの動作をモックする方法
- TestContainersでElasticsearchのインスタンスを起動する方法
前提として、Elastic社がサポートしている公式のGoクライアントを利用します。
こちらのライブラリについてはこの記事をご参照ください。
今回の記事のソースコードは、GitHubで公開しています。本記事で紹介するのはテストコードの部分となります。
|--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のレスポンス 3.で利用します
| | | |--test_data_2.json # BulkInsert用のテストデータ 4.で利用します
| |--go
| |--kibana
2.本記事の対象者
- Elasticsearchとやり取りするGoコードの単体テスト方法を知りたい方
3.Elasticsearchサーバーの動作をモックする方法
この方法はgomockを利用してテスト中に呼ばれるべき関数とElasticSearchの応答結果を指定して単体テストを実行する方法になります。
ElasticSearchの応答結果を用意します。test_data_1.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": "テストラーメン3",
"property": "",
"alphabet": "",
"name_kana": "てすとらーめん3",
テスト対象のコードは下記になります。
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
}
上記の①②部分をモック化し、テストコードを書いていきます。
モック化の方法はこちらの記事が参考になります。
// ①ElasticSearchの接続情報を作成する
e, err := r.EsCon.ConnectElastic(r.EsHost)
// ②ElasticSearchと接続し、検索を実施する
res, err := r.EsCon.Search(r.EsIndexShop, buf, e)
作成した正常系のテストコードは下記の通りです。
テスト実施の箇所で用意したレスポンスから値が取得できていることを確認します。
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, "テストラーメン2")
assert.Equal(t, fs.Hits.Hits[2].Source.Id, int64(18007))
assert.Equal(t, fs.Hits.Hits[2].Source.Name, "テストラーメン3")
assert.Equal(t, err, nil)
})
次にエラー時のテストコードを用意します。
ここではエラー値をgomockの結果にセットし、②のエラー時の動作をテストしています。
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)
})
上記テストを実行すると下記の結果が返却されます。
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を使ってElasticSearchのコンテナを立ち上げ、データを投入し、ElasticSearchの動作を確認する方法になります。
このテストでは、Elasticsearchにクエリを実行し、作成したクエリが機能するかをテストすることができます。
まずはテスト実施する前にElasticSearchのテストコンテナの準備をします。
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.")
}
// 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の使い方についてはこちらを参照ください。
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秒スリープを入れて、インデックスを作成します。
// 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
になります。
// データ投入 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
}
以下、テストコードの部分になります。
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, "テストラーメン2")
assert.Equal(t, fs.Hits.Hits[2].Source.Id, int64(18007))
assert.Equal(t, fs.Hits.Hits[2].Source.Name, "テストラーメン3")
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で単体テストを実行する2つの方法を紹介しました。
どのアプローチを選択するかはテスト目標によって異なりますが、
1つ目はアプリの実際のロジックのテスト、2つ目はElasticsearch間の実際の接続に関するテストに役立つことがわかりました。