使用Go语言和ElasticSearch来执行单元测试
首先
以前参与了一个Go语言+ElasticSearch项目,所以我将介绍使用Go语言和ElasticSearch进行单元测试的两种方法。
-
- 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
}
我将对上面的1和2部分进行模拟,并编写测试代码。
这篇文章将提供模拟的方法作为参考。
// ①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
使用模拟工具的方法如上所述。
通过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执行单元测试的方法。
选择哪种方法取决于测试目标的不同,第一种是测试应用程序实际逻辑,第二种是测试Elasticsearch之间的实际连接。
文献引用
-
- Boosting your Tests with Elasticsearch using TestContainers-Go
-
- testcontainers-goを使ってテスト時にカジュアルにコンテナを起動する
-
- gomockでGoのインターフェースのmockを作成してテストを実行する
-
- Testing Elasticsearch App In Go
- How to unit test go code that interacts with Elasticsearch