Installation
# Gemfilegroup :test do gem 'esse-rspec'endrequire 'esse/rspec'Loading the gem auto-includes Esse::RSpec::ClassMethods and Esse::RSpec::Matchers into every RSpec.configure example group. Nothing else to wire up.
Stubbing requests
esse_receive_request(:method) wraps RSpec’s receive against the Esse transport. It accepts the name of any Esse::Transport instance method (:search, :get, :index, :update, :delete, :bulk, :count, :indices, …). The matcher works on both index classes and Esse::Cluster instances.
Return a response
expect(ProductsIndex).to esse_receive_request(:search) .with(body: { query: { match_all: {} }, size: 10 }) .and_return('hits' => { 'total' => 0, 'hits' => [] })
query = ProductsIndex.search(query: { match_all: {} }, size: 10)query.response.total # => 0When the target is an Esse::Index subclass, the matcher auto-injects index: [...] into the expected arguments using Esse::Search::Query.normalize_indices, so you only need to declare the body (and any other params you want to assert on).
Loose matching with RSpec argument matchers
.with(...) also accepts any single RSpec argument matcher instead of a literal Hash — useful when you care about a subset of the request:
expect(PostsIndex).to esse_receive_request(:search) .with( hash_including( _source: false, body: hash_including('aggregations' => anything), ), ) .and_return('aggregations' => { 'tags' => { 'buckets' => [] } })This asserts that _source: false and a body containing an aggregations key are present, without constraining the rest of the payload. You can mix and match any composable matcher — hash_including, a_hash_including, array_including, an_instance_of, anything, match(/.../), etc.
Note: when you pass a matcher object to .with(...), the index auto-injection is skipped — the matcher becomes the whole expectation. If you need to assert on the target index alongside the matcher, include it explicitly:
expect(PostsIndex).to esse_receive_request(:search) .with(hash_including(index: ['posts'], body: hash_including('aggregations' => anything))) .and_return(...)Raise an HTTP status error
expect(ProductsIndex).to esse_receive_request(:search) .with(body: { query: { match_all: {} }, size: 10 }) .and_raise_http_status(500, { 'error' => 'Something went wrong' })
expect { ProductsIndex.search(query: { match_all: {} }, size: 10).response}.to raise_error(Esse::Transport::InternalServerError)and_raise_http_status maps the status code to the matching Esse::Transport::* subclass (300–510 are covered; anything unmapped falls back to Esse::Transport::ServerError).
Raise a specific error class
expect(ProductsIndex).to esse_receive_request(:search) .and_raise(Esse::Transport::BadRequestError, { 'error' => 'bad body' })Cluster-level stubs
Use Esse.cluster (or Esse.cluster(:name)) as the target when you want to stub transport calls that aren’t index-scoped:
expect(Esse.cluster(:default)).to esse_receive_request(:search) .with(index: 'geos_*', body: { query: { match_all: {} }, size: 10 }) .and_return('hits' => { 'total' => 0, 'hits' => [] })
Esse.cluster(:default).search('geos_*', body: { query: { match_all: {} }, size: 10 })expect(Esse.cluster).to esse_receive_request(:get) .with(id: '1', index: 'products') .and_return('_id' => '1', '_source' => { title: 'Product 1' })
Esse.cluster.api.get('1', index: 'products')Call counts
expect(ProductsIndex).to esse_receive_request(:search).once.and_return(...)expect(ProductsIndex).to esse_receive_request(:search).twice.and_return(...)expect(ProductsIndex).to esse_receive_request(:search).exactly(3).and_return(...)expect(ProductsIndex).to esse_receive_request(:search).at_least(1).and_return(...)expect(ProductsIndex).to esse_receive_request(:search).at_most(5).and_return(...)Call through to the real transport
expect(ProductsIndex).to esse_receive_request(:search).and_call_originalStubbing Esse::Index classes
When a spec needs an index class that doesn’t exist in the codebase (or you want an isolated one for a single example), use stub_esse_index:
before do stub_esse_index('products') do repository :product, const: true do # mappings, collection, document, etc. end endend
it 'defines the ProductsIndex class' do expect(ProductsIndex).to be < Esse::Index expect(ProductsIndex::Product).to be < Esse::Index::RepositoryendThe class is installed via stub_const, so it disappears at the end of the example. The first argument is camelized and suffixed with Index if needed ('products' → ProductsIndex, 'ProductsIndex' → ProductsIndex).
Need a different superclass?
stub_esse_index('products', CustomIndex) do # ...endFor arbitrary stubbed classes (not necessarily Esse::Index subclasses), use the lower-level helper:
stub_esse_class('My::Service', SomeBaseClass) do def call; endendPatterns
Shared helpers for fixtures
module IndexStubs def stub_products_index stub_esse_index('products') do repository :product, const: true do document { |record| { id: record[:id], name: record[:name] } } end end endend
RSpec.configure { |c| c.include IndexStubs }Verifying bulk operations
expect(ProductsIndex).to esse_receive_request(:bulk) .with(hash_including(body: array_including(an_instance_of(Hash)))) .and_return('errors' => false, 'items' => [])Legacy stub_esse_search
An older convenience helper still exists:
stub_esse_search(ProductsIndex, body: { ... }) do { 'hits' => { 'total' => 0, 'hits' => [] } }endPrefer esse_receive_request(:search) — it’s composable with .with, .and_raise, and call-count modifiers. stub_esse_search is kept for backward compatibility and may be removed in a future release.