はじめに
RspecでElasticsearchを使ったテストを書く方法を紹介していきます。
elasticsearch-railsを使うことが前提の記事になります。
また、本記事で出てくるサンプルや環境は、RailsとElasticsearchで検索機能をつくり色々試してみる - その1:サンプルアプリケーションの作成をもとにしています。
環境
- Ruby 2.5.3
- Rails 5.2.2
- Elasticsearch 6.5.4
gem
- rspec-rails 3.8.2
- elasticsearch-model 6.0.0
- elasticsearch-rails 6.0.0
テストで使用するElasticsearchのクラスターについて
調査する中でelasticsearch-extensions
gemを使ってテスト用のclusterを立てる記事が多く見つかりましたが、ローカルの環境で開発用のclusterが起動している状態であれば別に起動する必要はないかと思い使用しませんでした。
テスト対象のコード
タイトルと説明カラムを持つ漫画モデルを検索する処理を以下のようにconcernsに実装していた場合のテストを考えます。
class Manga < ApplicationRecord
include MangaSearchable
end
module MangaSearchable
extend ActiveSupport::Concern
included do
include Elasticsearch::Model
# index名
# 環境名を入れることで開発用とは別にrspec用のindexを作成する
index_name "es_manga_#{Rails.env}"
# マッピング情報
settings do
mappings dynamic: 'false' do
indexes :id, type: 'integer'
indexes :title, type: 'text', analyzer: 'kuromoji'
indexes :description, type: 'text', analyzer: 'kuromoji'
end
end
def as_indexed_json(*)
attributes
.symbolize_keys
.slice(:id, :title, :description)
end
end
class_methods do
def create_index!
client = __elasticsearch__.client
# すでにindexを作成済みの場合は削除する
client.indices.delete index: self.index_name rescue nil
client.indices.create(index: self.index_name,
body: {
settings: self.settings.to_hash,
mappings: self.mappings.to_hash
})
end
# 今回テストする検索処理
def es_search(query)
__elasticsearch__.search({
query: {
multi_match: {
fields: %w(title description),
type: 'cross_fields',
query: query,
operator: 'and'
}
}
})
end
end
end
Rspec
index作成について
それぞれのテストを独立して行うため、ケース毎にindexを作成するようにします。また、elasticsearchに関わるテストでのみindex作成を行えばよいのでmeta情報でindex作成を制御できるようにするのがよいと思います。
以下はその例です。
RSpec.configure do |config|
config.before :each do |example|
if example.metadata[:elasticsearch]
Manga.create_index!
end
end
テストケース
require 'rails_helper'
# elasticsearch: true を追加しindexをテストケース毎に再作成する
RSpec.describe MangaSearchable, elasticsearch: true do
describe '.es_search' do
describe '検索ワードにマッチする漫画の検索' do
let!(:manga_1) do
create(:manga, title: 'キングダム', description: '時は紀元前―。いまだ一度も統一...')
end
let!(:manga_2) do
create(:manga, title: '僕のヒーローアカデミア', description: '多くの人間が“個性という力を持つ...')
end
let!(:manga_3) do
create(:manga, title: 'はたらく細胞', description: '人間1人あたりの細胞の数、およそ60兆個...')
end
before :each do
# 作成したデータをelasticsearchに登録する
# refresh: true を追加することで登録したデータをすぐに検索できるようにする
Manga.__elasticsearch__.import(refresh: true)
end
def search_manga_ids
Manga.es_search(query).records.pluck(:id)
end
context '検索ワードがタイトルにマッチする場合' do
let(:query) { 'キングダム' }
it '検索ワードにマッチする漫画を取得する' do
expect(search_manga_ids).to eq [manga_1.id]
end
end
context '検索ワードが本文にマッチする場合' do
let(:query) { '60兆個' }
it '検索ワードにマッチする漫画を取得する' do
expect(search_manga_ids).to eq [manga_3.id]
end
end
context '検索ワードが複数ある場合' do
let(:query) { '人間 個性' }
it '両方の検索ワードにマッチする漫画を取得する' do
expect(search_manga_ids).to eq [manga_2.id]
end
end
end
end
end
refresh: true オプション
ポイントは、import時にrefresh: true オプションを追加する点です。
ドキュメントより
Elasticsearch is a near-realtime search platform. What this means is there is a slight latency (normally one second) from the time you index a document until the time it becomes searchable.
documentを登録してから、検索ができるようになるまで通常は1秒かかります。(1秒間隔で更新を反映しているようですが、この間隔は変更することもできます。)
そのため、データをimportしてすぐに検索を行うと、更新が反映されていないためテストに失敗してしまいます。refresh: true
オプションを渡すことで、importするタイミングでリフレッシュされ検索できるようになります。
参考
Rails から Elasticsearch を使っているときのテストの書き方(elasticsearch-rails, RSpec)
Elasticsearchを使ったテストを書くときにsleep 1するのはやめましょう
ElasticSearchのインデクシングを高速化する