使用Go语言和ElasticSearch来执行单元测试

本文发布于2022年Go Advent Calendar的第15天。

 

首先

以前参与了一个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
广告
将在 10 秒后关闭
bannerAds