Skip to content

About

RSpec matchers and macros for Mongoid.

Topics

Resources

Contributing

Stars

494 stars

Watchers

18 watching

Forks

Repository files navigation

Gem Version Test Status Rubocop Status Coverage Status

CI collects SimpleCov coverage on Ruby 3.4 with Mongoid 9 and MongoDB 8.0 and submits one report to Coveralls using GitHub's workflow token. To generate a local report, run COVERAGE=true BUNDLE_GEMFILE=gemfiles/mongoid_9.gemfile bundle install followed by COVERAGE=true BUNDLE_GEMFILE=gemfiles/mongoid_9.gemfile bundle exec rake spec. Reports are written to coverage/.

The mongoid-rspec library provides a collection of RSpec-compatible matchers that help to test Mongoid documents.

Tested against:

  • MRI: 2.6.x, 2.7.x, 3.0.x, 3.1.x, 3.2.x
  • Mongoid: 4, 5, 6, 7, 8, 9

See .github/workflows/rspec.yml for the latest test matrix.

Installation

Drop this line into your Gemfile:

group :test do
  gem 'mongoid-rspec'
end

Compatibility

This gem is compatible with Mongoid 3, 4, 5, 6, 7, 8, 9.

Configuration

Rails

Add to your rails_helper.rb file

require 'mongoid-rspec'

RSpec.configure do |config|
  config.include Mongoid::Matchers, type: :model
end

Using Mongoid and ActiveRecord with shoulda-matchers

Both libraries define matcher names such as validate_length_of and belong_to, but their chain methods and implementations differ. In an application with both Mongoid and ActiveRecord models, include Mongoid matchers only for a custom :mongoid_model type instead of all :model examples. Keep Shoulda's ActiveModel and ActiveRecord matchers scoped to :model:

require 'mongoid-rspec'
require 'shoulda/matchers'

RSpec.configure do |config|
  config.include Mongoid::Matchers, type: :mongoid_model
  config.include Shoulda::Matchers::ActiveModel, type: :model
  config.include Shoulda::Matchers::ActiveRecord, type: :model
end

RSpec.describe MongoidAddress, type: :mongoid_model do
  it { is_expected.to validate_length_of(:name).with_maximum(255) }
  it { is_expected.to belong_to(:city).as_inverse_of(:addresses) }
end

RSpec.describe ActiveRecordAddress, type: :model do
  it { is_expected.to validate_length_of(:name).is_at_most(255) }
  it { is_expected.to belong_to(:city).inverse_of(:addresses) }
end

Replace the existing unscoped or type: :model inclusion of Mongoid::Matchers; do not keep it alongside the scoped configuration. Explicitly tag Mongoid example groups with type: :mongoid_model, including those under spec/models, where rspec-rails would otherwise infer :model. Do not include Shoulda's model matcher modules globally or directly in those Mongoid groups. This avoids depending on module inclusion order and preserves each library's existing matcher API.

Other

Add to your spec_helper.rb file.

require 'mongoid-rspec'

RSpec.configure do |config|
  config.include Mongoid::Matchers
end

Matchers

be_mongoid_document

class Post
  include Mongoid::Document
end

RSpec.describe Post, type: :model do
  it { is_expected.to be_mongoid_document }
end

be_dynamic_document

class User
  include Mongoid::Document
  include Mongoid::Attributes::Dynamic
end

RSpec.describe User, type: :model do
  it { is_expected.to be_dynamic_document }
end

have_timestamps

With full timestamps.

class Log
  include Mongoid::Document
  include Mongoid::Timestamps
end

RSpec.describe Log, type: :model do
  it { is_expected.to have_timestamps }
end

With short timestamps.

class User
  include Mongoid::Document
  include Mongoid::Timestamps::Short
end

RSpec.describe User, type: :model do
  it { is_expected.to have_timestamps.shortened }
end

With only creating or updating timestamps.

class Admin
  include Mongoid::Document
  include Mongoid::Timestamps::Create
  include Mongoid::Timestamps::Update
end

RSpec.describe Admin, type: :model do
  it { is_expected.to have_timestamps.for(:creating) }
  it { is_expected.to have_timestamps.for(:updating) }
end

With short creating or updating timestamps.

class Post
  include Mongoid::Document
  include Mongoid::Timestamps::Create::Short
end

RSpec.describe Short, type: :model do
  it { is_expected.to have_timestamps.for(:creating).shortened }
end

be_stored_in

class Post
  include Mongoid::Document

  store_in database: 'db1', collection: 'messages', client: 'secondary'
end

RSpec.describe Post, type: :model do
  it { is_expected.to be_stored_in(database: 'db1', collection: 'messages', client: 'secondary') }
end

It checks only those options, that you specify. For instance, test in example below will pass, even though expectation contains only database option.

class Comment
  include Mongoid::Document

  store_in database: 'db2', collection: 'messages'
end

RSpec.describe Comment, type: :model do
  it { is_expected.to be_stored_in(database: 'db2') }
end

It works fine with lambdas and procs.

class User
  include Mongoid::Document

  store_in database: ->{ Thread.current[:database] }
end

RSpec.describe Post, type: :model do
  it do
    Thread.current[:database] = 'db3'
    is_expected.to be_stored_in(database: 'db3')

    Thread.current[:database] = 'db4'
    is_expected.to be_stored_in(database: 'db4')
  end
end

have_index_for

class Article
  index({ title: 1 }, { unique: true, background: true })
  index({ title: 1, created_at: -1 })
  index({ category: 1 })
end

RSpec.describe Article, type: :model do
  it do
    is_expected
      .to have_index_for(title: 1)
      .with_options(unique: true, background: true)
  end
  it { is_expected.to have_index_for(title: 1, created_at: -1) }
  it { is_expected.to have_index_for(category: 1) }
end

execute_mongodb_command

Use execute_mongodb_command with a block expectation to verify that code issues a MongoDB command. Without an argument, the matcher passes when any command is issued on the block's thread. An expected command matches the entire application command document exactly, not just a subset or query selector; nested String and Symbol keys are normalized to Strings. Driver-added top-level $db, lsid, and $clusterTime fields are excluded from comparison, while all other command fields and values must match. The matcher ignores unrelated background-thread activity and raises NotImplementedError when the driver does not expose command monitoring, including the legacy driver used with Mongoid 4.

RSpec.describe FooService do
  it 'uses the cached result without querying MongoDB' do
    expect { FooService.recent_foos }.not_to execute_mongodb_command
  end
end

RSpec.describe Article do
  it 'executes the expected MongoDB command' do
    expected_command = {
      find: 'articles',
      filter: { 'title.en' => 'cached' },
      limit: 10,
      sort: { created_at: -1 }
    }

    expect do
      Article.where(title: 'cached').desc(:created_at).limit(10).to_a
    end.to execute_mongodb_command(expected_command)
  end
end

be_covered_query

Check that MongoDB's winning plan uses an index without fetching documents:

class Article
  index({ title: 1, _id: 1 })
end

RSpec.describe Article do
  it { expect(Article.where(title: 'Example').only(:title)).to be_covered_query }
end

Mongoid includes _id in an inclusive projection by default, so the index must cover it as well. The matcher examines all stages in the winning plan, including nested shard plans, and rejects plans containing FETCH or COLLSCAN. A covered plan does not guarantee that a query is fast; this matcher checks plan coverage, not execution time.

Use .using_index('title_1__id_1') or .using_indexes('first_index', 'second_index') to additionally require the exact set of index names used by the winning plan. Index names accept nonempty strings or symbols; order and repeated uses across shards do not matter. Rejected plans are ignored, and the query must still be covered.

expect(Article.where(title: 'Example').only(:title)).to be_covered_query.using_index('title_1__id_1')

Field Matchers

Chain .readonly to check fields declared with Mongoid's attr_readonly. This checks the declaration, not whether a new document currently allows writes. Like other field matcher options, it applies to every requested field and accepts class or instance subjects.

class Article
  include Mongoid::Document

  field :slug, type: String
  field :external_id, type: String
  attr_readonly :slug, :external_id
end

RSpec.describe Article do
  it { is_expected.to have_field(:slug).of_type(String).readonly }
  it { is_expected.to have_fields(:slug, :external_id).readonly }
end
RSpec.describe Article do
  it { is_expected.to have_field(:published).of_type(Mongoid::Boolean).with_default_value_of(false) }
  it { is_expected.to have_field(:allow_comments).of_type(Mongoid::Boolean).with_default_value_of(true) }
  it { is_expected.not_to have_field(:allow_comments).of_type(Mongoid::Boolean).with_default_value_of(false) }
  it { is_expected.not_to have_field(:number_of_comments).of_type(Integer).with_default_value_of(1) }
end

RSpec.describe User do
  it { is_expected.to have_fields(:email, :login) }
  it { is_expected.to have_field(:s).with_alias(:status) }
  it { is_expected.to have_fields(:birthdate, :registered_at).of_type(DateTime) }
end

Association Matchers

Use with_primary_key to check an association's primary key, including the default _id. Like with_foreign_key, it accepts a symbol or string and can be chained with other association options:

class Author
  include Mongoid::Document

  field :code, type: String
  has_many :posts, primary_key: :code, foreign_key: :author_code
end

class Post
  include Mongoid::Document

  belongs_to :author, primary_key: :code, foreign_key: :author_code
end

RSpec.describe Author do
  it { is_expected.to have_many(:posts).with_primary_key(:code).with_foreign_key(:author_code) }
end

RSpec.describe Post do
  it { is_expected.to belong_to(:author).with_primary_key('code') }
end
RSpec.describe User do
  it { is_expected.to have_many(:articles).with_foreign_key(:author_id).ordered_by(:title) }

  it { is_expected.to have_one(:record) }

  # can verify autobuild is set to true
  it { is_expected.to have_one(:record).with_autobuild }

  it { is_expected.to have_many :comments }

  # can also specify with_dependent to test if :dependent => :destroy/:destroy_all/:delete is set
  it { is_expected.to have_many(:comments).with_dependent(:destroy) }

  # can verify autosave is set to true
  it { is_expected.to have_many(:comments).with_autosave }

  it { is_expected.to embed_one :profile }

  it { is_expected.to have_and_belong_to_many(:children) }
  it { is_expected.to have_and_belong_to_many(:children).of_type(User) }
  # nonconflicting alternative when using shoulda-matchers
  it { is_expected.to have_and_belong_to_many_related(:children).of_type(User) }
end

RSpec.describe Profile do
  it { is_expected.to be_embedded_in(:user).as_inverse_of(:profile) }
end

RSpec.describe Article do
  it { is_expected.to belong_to(:author).of_type(User).as_inverse_of(:articles) }
  it { is_expected.to belong_to(:author).of_type(User).as_inverse_of(:articles).with_index }
  it { is_expected.to embed_many(:comments) }
end

# when the touch option is provided, then we can also verify that it is set

# by default, with_touch matches true when no parameters are provided
describe Article do
  it { is_expected.to belong_to(:author).of_type(User).as_inverse_of(:articles).with_index.with_touch }
end

# parameters are supported for explicit matching
describe Comment do
  it { is_expected.to be_embedded_in(:article).as_inverse_of(:comments).with_polymorphism.with_touch(true) }
end

describe Permalink do
  it { is_expected.to be_embedded_in(:linkable).as_inverse_of(:link).with_touch(false) } 
end

# touch can also take a symbol representing a field on the parent to touch
describe Record do
  it { is_expected.to belong_to(:user).as_inverse_of(:record).with_touch(:record_updated_at) }
end

RSpec.describe Comment do
  it { is_expected.to be_embedded_in(:article).as_inverse_of(:comments) }
  it { is_expected.to belong_to(:user).as_inverse_of(:comments) }
end

RSpec.describe Record do
  it { is_expected.to belong_to(:user).as_inverse_of(:record) }
end

RSpec.describe Site do
  it { is_expected.to have_many(:users).as_inverse_of(:site).ordered_by(:email.asc).with_counter_cache }
end

RSpec.describe Message do
  it { is_expected.to belong_to(:user).with_optional }
end

Validation Matchers

validate_length_of searches all applicable length validators for one matching the requested options. For example, a maximum-length validator added by has_secure_password does not hide a separately declared minimum-length validator. Chained length, message, and to_allow options must match a single validator; options from separate validators are not combined.

Use validate_length_of(:name).within(1..100).to_allow(nil: true) to check allow_nil: true, or .to_allow(blank: true) to check allow_blank: true. Both options can be combined, and the full keys allow_nil and allow_blank are also accepted. Values must be true or false; false requires an explicitly configured false (an omitted option does not match). Omitting to_allow leaves these options unchecked. Empty hashes, unknown keys, non-boolean values, and duplicate aliases are rejected with ArgumentError.

RSpec.describe Site do
  it { is_expected.to validate_presence_of(:name) }
  it { is_expected.to validate_uniqueness_of(:name) }
end

RSpec.describe User do
  it { is_expected.to validate_presence_of(:login) }
  it { is_expected.to validate_uniqueness_of(:login).scoped_to(:site) }
  it { is_expected.to validate_uniqueness_of(:email).case_insensitive.with_message("is already taken") }
  it { is_expected.to validate_format_of(:login).to_allow("valid_login").not_to_allow("invalid login") }
  it { is_expected.to validate_associated(:profile) }
  it { is_expected.to validate_exclusion_of(:login).to_not_allow("super", "index", "edit") }
  it { is_expected.to validate_inclusion_of(:role).to_allow("admin", "member") }
  it { is_expected.to validate_confirmation_of(:email) }
  it { is_expected.to validate_presence_of(:age).on(:create, :update) }
  it { is_expected.to validate_numericality_of(:age).on(:create, :update) }
  it { is_expected.to validate_inclusion_of(:age).to_allow(23..42).on([:create, :update]) }
  it { is_expected.to validate_presence_of(:password).on(:create) }
  it { is_expected.to validate_presence_of(:provider_uid).on(:create) }
  it { is_expected.to validate_inclusion_of(:locale).to_allow([:en, :ru]) }
end

RSpec.describe Article do
  it { is_expected.to validate_length_of(:title).within(8..16) }
  it { is_expected.to validate_absence_of(:deletion_date) }
end

RSpec.describe Visitor do
  it { is_expected.to validate_length_of(:name).with_maximum(160).with_minimum(1) }
end

RSpec.describe Profile do
  it { is_expected.to validate_numericality_of(:age).greater_than(0) }
end

RSpec.describe MovieArticle do
  it { is_expected.to validate_numericality_of(:rating).to_allow(:greater_than => 0).less_than_or_equal_to(5) }
  it { is_expected.to validate_numericality_of(:classification).to_allow(:even => true, :only_integer => true, :nil => false) }
end

RSpec.describe Person do
   # in order to be able to use the custom_validate matcher, the custom validator class (in this case SsnValidator)
   # should redefine the kind method to return :custom, i.e. "def self.kind() :custom end"
  it { is_expected.to custom_validate(:ssn).with_validator(SsnValidator) }
end

# If you're using validators with if/unless conditionals, spec subject must be object instance
# This is supported on Mongoid 4 and newer
Rspec.describe User do
  context 'when user has `admin` role' do
    subject { User.new(role: 'admin') }

    it { is_expected.to validate_length_of(:password).greater_than(20) }
  end

  context 'when user does not have `admin` role' do
    subject { User.new(role: 'member') }

    it { is_expected.not_to validate_length_of(:password) }
  end
end

Mass Assignment Matcher

RSpec.describe User do
  it { is_expected.to allow_mass_assignment_of(:login) }
  it { is_expected.to allow_mass_assignment_of(:email) }
  it { is_expected.to allow_mass_assignment_of(:age) }
  it { is_expected.to allow_mass_assignment_of(:password) }
  it { is_expected.to allow_mass_assignment_of(:password) }
  it { is_expected.to allow_mass_assignment_of(:role).as(:admin) }

  it { is_expected.not_to allow_mass_assignment_of(:role) }
end

Accepts Nested Attributes Matcher

RSpec.describe User do
  it { is_expected.to accept_nested_attributes_for(:articles) }
  it { is_expected.to accept_nested_attributes_for(:comments) }
end

RSpec.describe Article do
  it { is_expected.to accept_nested_attributes_for(:permalink) }
end

Contributing

You're encouraged to contribute to this library. See CONTRIBUTING for details.

bundle exec rake runs the unit suite without third-party integration dependencies. Each integration has its own Appraisal bundle and directory under spec/integration/. For example, run BUNDLE_GEMFILE=gemfiles/shoulda_matchers.gemfile bundle install, then BUNDLE_GEMFILE=gemfiles/shoulda_matchers.gemfile bundle exec rspec spec/integration/shoulda_matchers. To add an integration, define its dependencies in Appraisals, generate its gemfile, add its specs under a matching directory, and add its name to the integration matrix in .github/workflows/rspec.yml. CI runs each listed integration in a separate process and bundle.

Copyright and License

Copyright (c) 2009-2018 Evan Sagge and Contributors.

MIT License. See LICENSE for details.

About

RSpec matchers and macros for Mongoid.

Topics

Resources

Contributing

Stars

494 stars

Watchers

18 watching

Forks

Releases

Packages

Used by

Contributors

Languages