不使用模拟实现单元测试Elasticsearch访问层

在本文中要做的事情。

    Elasticsearchへのアクセスレイヤーに対する単体テストを書く際にmockせず本物のElasticsearchを利用する実装方法の紹介

我们在此存储库中公开了此次实施的代码。

目标读者

    • Elasticsearchを使ったアプリケーションを実装したことがある方

 

    Elasticsearch周りの単体テストの書き方に悩んでいる方

使用语言和库

    • Go言語 1.18

olivere/elastic (非公式 Elasticsearch clientライブラリー)

背景:

阅读了t_wada先生的推文后,我突然产生了一个疑问,那就是如果将Elasticsearch用作数据库,它的实现会是怎样的呢?这就是我写这篇文章的背景。

如果在测试时不使用Mock而使用真正的Elasticsearch,那么我们需要在本地的Docker容器上部署Elasticsearch。但是,相比MySQL或SQL Server等数据库,设置时的索引创建和文档添加等步骤可能会更加复杂。我担心这一点,所以决定实际编写测试代码进行尝试。

 

实施

只需要一种选择,以下是对”前提”的中文本地化改写方式:

先决条件

我們將範圍限定於在Repository層中定義的Search方法中,該方法執行了對Elasticsearch的搜索查詢並返回文件結果,作為編寫測試的程式碼的目標。

搜索方法的参数

keyword: 検索キーワード

indexName: (Elasticsearchに登録されている)インデックス名

package repository

import (
	"context"
	"encoding/json"
	"github.com/kurakura967/unittest-for-es/app/model"
	"github.com/olivere/elastic/v7"
)

type esHandler struct {
	client *elastic.Client
}

func NewEsHandler(client *elastic.Client) *esHandler {
	return &esHandler{client: client}
}

func (e *esHandler) Search(ctx context.Context, keyword, indexName string) ([]*model.SearchResult, error) {
	termQuery := elastic.NewMatchPhraseQuery("title", keyword)
	res, err := e.client.Search().
		Index(indexName).
		Query(termQuery).
		From(0).Size(10).
		Pretty(true).
		Do(ctx)

	if err != nil {
		return nil, err
	}

	searchArray := make([]*model.SearchResult, 0)

	if res.TotalHits() > 0 {

		for _, hit := range res.Hits.Hits {

			var searchResult model.SearchResult
			if err := json.Unmarshal(hit.Source, &searchResult); err != nil {
				return nil, err
			}
			searchArray = append(searchArray, &searchResult)
		}
	} else {
		return searchArray, nil
	}
	return searchArray, nil
}

另外,Search方法的定义是返回一个包含以下模型层定义的SearchResult结构体元素的数组。

package model

type SearchResult struct {
	Author string `json:"author"`
	Title  string `json:"title"`
}

考试代码

我已经按照以下方式实现了对Search方法的测试代码。
这个测试代码的目的是测试从作为搜索查询关键字(keyword)和执行搜索的索引名称(indexName)的参数中是否可以得到期望的搜索结果。

package repository_test

import (
	"context"
	"github.com/google/go-cmp/cmp"
	"github.com/kurakura967/unittest-for-es/app/model"
	"github.com/kurakura967/unittest-for-es/app/repository"
	"testing"
)

func TestSearch(t *testing.T) {
	ctx := context.Background()

	tests := []struct {
		testTitle string
		keyword   string
		expected  []*model.SearchResult
	}{
		{
			testTitle: "searchTest1",
			keyword:   "Hamlet",
			expected: []*model.SearchResult{
				{
					Author: "William Shakespeare",
					Title:  "Hamlet",
				},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.testTitle, func(t *testing.T) {
			repo := repository.NewEsHandler(client)
			got, err := repo.Search(ctx, tt.keyword, IndexName)
			if err != nil {
				t.Fatal(err)
			}

			if diff := cmp.Diff(tt.expected, got); diff != "" {
				t.Errorf("SearchResults is unmatched (-want, +got): %s\n", diff)
			}
		})
	}

}

当然,如果按照这样的方式进行测试,会导致失败,因此我们将创建一个可以进行测试的环境。

$ go test -v -count=1 ./...
FAIL	github.com/kurakura967/unittest-for-es/app/repository	5.375s

创建测试环境

在执行测试时,连接到本地启动的Elasticsearch,并按照以下步骤实现环境,以完成上述测试。

    1. 在进行测试时,将可连接的Elasticsearch部署在本地的Docker容器中。

 

    1. 在执行单元测试之前,首先创建执行搜索查询的索引以及添加作为搜索结果返回的文档(设置)。

 

    测试完成后,删除已创建的索引(清理工作)。

在本地的Docker容器中部署Elasticsearch。

使用以下的 docker-compose.test.yaml 文件在本地的 Docker 容器中启动 Elasticsearch。
此外,本次使用的 Elasticsearch 版本为7系列(7.16.3)。

version: "3"

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.16.3
    container_name: elasticsearch
    environment:
      - xpack.security.enabled=false
      - discovery.type=single-node
    ports:
      - "9200:9200"
    ulimits:
      memlock:
        soft: -1
        hard: -1

确认 Elasticsearch 在本机成功启动。

# Dockerコンテナを起動する
$ docker compose up -d --build

# Elasticsearchのクラスターが無事起動したことを確認
$ curl -X GET "http://localhost:9200/_cluster/health" | jq .
{
  "cluster_name": "docker-cluster",
  "status": "green",
  "timed_out": false,
  "number_of_nodes": 1,
  "number_of_data_nodes": 1,
  "active_primary_shards": 1,
  "active_shards": 1,
  "relocating_shards": 0,
  "initializing_shards": 0,
  "unassigned_shards": 0,
  "delayed_unassigned_shards": 0,
  "number_of_pending_tasks": 0,
  "number_of_in_flight_fetch": 0,
  "task_max_waiting_in_queue_millis": 0,
  "active_shards_percent_as_number": 100
}

2. 设置成熟

我们将使用func TestMain(m *testing.M)来实现创建索引和添加文档等常见操作。

以下是在设置过程中执行的函数。请按顺序列出每个函数的简要说明。

connectEs()関数

ローカルに起動したElasticsearchに接続する。

デフォルトのエンドポイントはhttp://127.0.0.1:9200になります。

setupIndex()関数

インデックスを作成する。

settingsやmappingの情報は簡潔にするため最小限にしています。

setupDocument()関数

作成したインデックスにドキュメントを追加する。

今回は簡易的なテストにするため1件のドキュメントのみ追加します。

setup()関数

上記3つの関数をまとめ順に実行していきます。

package repository_test

import (
	"context"
	"github.com/kurakura967/unittest-for-es/app/model"
	"github.com/olivere/elastic/v7"
	"log"
	"os"
	"strconv"
	"testing"
)

const IndexName = "test_index"

var client *elastic.Client

func connectEs() error {
	var err error
	client, err = elastic.NewClient(elastic.SetSniff(false))
	if err != nil {
		log.Println("failed to connect es")
		return err
	}
	return nil
}

func setupIndex() error {
	mapping := `{
	  "settings": {
		"index": {
		  "refresh_interval": "60s",
		  "auto_expand_replicas": "0-all"
		}
	  },
	  "mappings": {
		"properties": {
		  "author": {
			"type": "text"
		  },
		  "title": {
			"type": "text"
		  }
		}
	  }
	}`

	index, err := client.CreateIndex(IndexName).BodyString(mapping).Do(context.Background())
	if err != nil {
		return err
	}

	if !index.Acknowledged {
		panic("failed to create index")
	}
	return nil
}

func setupDocument() error {
	docs := []*model.SearchResult{
		{
			Author: "William Shakespeare",
			Title:  "Hamlet",
		},
	}

	for i, d := range docs {
		put, err := client.Index().Index(IndexName).OpType("index").Id(strconv.Itoa(i)).BodyJson(d).Do(context.Background())
		if err != nil {
			return err
		}
		log.Printf("Add document to index: %s, type: %s \n", put.Index, put.Type)
	}

	_, err := client.Refresh(IndexName).Do(context.Background())
	if err != nil {
		return err
	}

	return nil
}

func setup() (err error) {
	err = connectEs()
	if err != nil {
		return err
	}
	err = setupIndex()
	if err != nil {
		return err
	}
	err = setupDocument()
	if err != nil {
		return err
	}
	return nil
}

func TestMain(m *testing.M) {
	err := setup()
	if err != nil {
		os.Exit(1)
	}
	defer cleanup()
	m.Run()
}

3. 清洁操作

由于Elasticsearch不允许注册同名索引,因此我们需要在执行测试后实施清理操作,删除创建的索引,以便在 Docker 容器运行的同时能够多次执行测试。

deleteIndex()関数

インデックスを削除する。

cleanup()関数

インデックスの削除とElasticsearchへの内部接続を閉じる


const IndexName = "test_index"

...省略

func cleanup() {
	deleteIndex()
	client.Stop()
}

func deleteIndex() error {
	deleteIndex, err := client.DeleteIndex(IndexName).Do(context.Background())
	if err != nil {
		return err
	}
	if !deleteIndex.Acknowledged {
		panic("failed to delete index")
	}
	log.Println("deleted index")
	return nil
}

func TestMain(m *testing.M) {
	err := setup()
	if err != nil {
		os.Exit(1)
	}
	defer cleanup()
	m.Run()
}

考试的进行

在本地的Docker容器中启动Elasticsearch,并再次运行测试。

$ export COMPOSE_FILE=docker-compose.test.yaml
$ docker compose up -d --build
$ go test -v -count=1 ./...
...
2022/12/16 15:27:50 Add document to index: test_index, type: _doc 
=== RUN   TestSearch
=== RUN   TestSearch/searchTest1
--- PASS: TestSearch (0.01s)
    --- PASS: TestSearch/searchTest1 (0.01s)
PASS
2022/12/16 15:27:50 deleted index
ok  	github.com/kurakura967/unittest-for-es/app/repository	0.560s
...

我确认测试通过没有问题。

附加:关于模仿的测试代码

因为经常能看到这种模式,即对访问数据库的Repository层进行模拟以进行上层的Service层测试。所以,我想以本次的实现为例来写一下。

首先,以下是Service层的实现,作为测试对象进行记录。

package service

import (
	"context"
	"github.com/kurakura967/unittest-for-es/app/model"
	"github.com/kurakura967/unittest-for-es/app/repository"
	"log"
)

type SearchService struct {
	repo repository.Searcher
}

func NewSearchService(repo repository.Searcher) *SearchService {
	return &SearchService{repo: repo}
}

func (s *SearchService) GetSearchService(ctx context.Context, keyword, indexName string) (sr []*model.SearchResult, err error) {
	res, err := s.repo.Search(ctx, keyword, indexName)
	if err != nil {
		log.Println(err)
	}
    // 何某かの処理
    // 今回は簡単にするためそのまま返す
	return res, nil
}


我们假设已经在上述中实现了Repository层的Search方法,并将其实现在Searcher接口中。

package repository

import (
	"context"
	"github.com/kurakura967/unittest-for-es/app/model"
)

type Searcher interface {
	Search(ctx context.Context, keyword, indexName string) ([]*model.SearchResult, error)
}


测试代码

我们在存储库层的Search方法上进行模拟,以便让特定值的结构体发生变化。(这可能被称为自作自演的一点吧)

package testdata

import (
	"context"
	"github.com/kurakura967/unittest-for-es/app/model"
)

type EsMockHandler struct{}

func (e EsMockHandler) Search(ctx context.Context, keyword, indexName string) ([]*model.SearchResult, error) {
	return []*model.SearchResult{
		{
			Author: "William Shakespeare",
			Title:  "Hamlet",
		},
	}, nil
}

我已经按以下方式实现了Service层的测试。这次我们将Repository层的Search方法返回的结构直接返回给Service层,所以测试数据与模拟数据是一致的。

package service_test

import (
	"context"
	"github.com/google/go-cmp/cmp"
	"github.com/kurakura967/unittest-for-es/app/model"
	"github.com/kurakura967/unittest-for-es/app/service"
	"github.com/kurakura967/unittest-for-es/app/service/testdata"
	"testing"
)

func TestSearchService(t *testing.T) {
	ctx := context.Background()
	ser := service.NewSearchService(testdata.EsMockHandler{})

	tests := []struct {
		testTitle string
		keyword   string
		expected  []*model.SearchResult
	}{
		{
			testTitle: "searchServiceTest1",
			keyword:   "Hamlet",
			expected: []*model.SearchResult{
				{
					Author: "William Shakespeare",
					Title:  "Hamlet",
				},
			},
		},
	}
	for _, tt := range tests {
		got, err := ser.GetSearchService(ctx, tt.keyword, "test_index")
		if err != nil {
			t.Fatal(err)
		}

		if diff := cmp.Diff(tt.expected, got); diff != "" {
			t.Errorf("SearchResults is unmatched (-want, +got): %s\n", diff)
		}
	}

}


我能确认测试顺利通过了。

$ go test -v -count=1 ./...
....
=== RUN   TestSearchService
--- PASS: TestSearchService (0.00s)
PASS
ok  	github.com/kurakura967/unittest-for-es/app/service	0.418s
广告
将在 10 秒后关闭
bannerAds