本記事でやること
- Elasticsearchへのアクセスレイヤーに対する単体テストを書く際にmockせず本物のElasticsearchを利用する実装方法の紹介
今回実装したコードはこちらのレポジトリで公開しております。
対象読者
- Elasticsearchを使ったアプリケーションを実装したことがある方
- Elasticsearch周りの単体テストの書き方に悩んでいる方
使用言語とライブラリー
- Go言語 1.18
- olivere/elastic (非公式 Elasticsearch clientライブラリー)
背景
以下、t_wadaさんのツイートを拝見し、DBとしてElasticsearchを使っている場合はどのような実装になるのかなとふと疑問に思ったのが、本記事を執筆した背景になります。
テスト時にmockせず本物のElasticsearchを利用するとなると、ローカルのDockerコンテナ上にElasticsearchを立てることになるが、インデックスの作成やドキュメントの追加などセットアップ時の手順がMySQLやSQLServerなどのDBよりも煩雑になるイメージがありました。その点も気になり実際にテストコードを書いてみました。
実装
前提
テストを書く対象のコードとして、以下のようなElasticsearchへ検索クエリを実行しドキュメントの結果を返すというRepository層で定義されたSearch
メソッドにスコープを限定します。
Search
メソッドの引数
-
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コンテナに立てる
- 検索クエリを実行するインデックスの作成や検索結果として返すドキュメントの追加を単体テストを実行する前に行う(セットアップ)
- テストが完了したら作成したインデックスを削除する(クリーンアップ)
1. ElasticsearchをローカルのDockerコンテナに立てる
以下、docker-compose.test.yaml
を利用しローカルのDockerコンテナでElasticsearchを起動します。
また、今回利用するElasticsearchのVersionは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
になります。
-
デフォルトのエンドポイントは
- ローカルに起動したElasticsearchに接続する。
-
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()
}
テストの実行
ElasticsearchをローカルのDockerコンテナ上に起動した状態で再度テストを実行します。
$ 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
...
無事テストが通ったことを確認することができました。
おまけ: mockした場合のテストコード
DBへアクセスするRepository層をmockし上位の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)
}
テストコード
Repository層のSearch
メソッドを以下のようにmockし特定の値を持った構造体が変えるようにします。(この点が自作自演と言われる点でしょうか)
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層が返すということをしているので、テストデータがmockしたデータと一致しています。
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