不使用模拟实现单元测试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,并按照以下步骤实现环境,以完成上述测试。
-
- 在进行测试时,将可连接的Elasticsearch部署在本地的Docker容器中。
-
- 在执行单元测试之前,首先创建执行搜索查询的索引以及添加作为搜索结果返回的文档(设置)。
- 测试完成后,删除已创建的索引(清理工作)。
在本地的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