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.
Drop this line into your Gemfile:
group :test do
gem 'mongoid-rspec'
endThis gem is compatible with Mongoid 3, 4, 5, 6, 7, 8, 9.
Add to your rails_helper.rb file
require 'mongoid-rspec'
RSpec.configure do |config|
config.include Mongoid::Matchers, type: :model
endBoth 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) }
endReplace 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.
Add to your spec_helper.rb file.
require 'mongoid-rspec'
RSpec.configure do |config|
config.include Mongoid::Matchers
endclass Post
include Mongoid::Document
end
RSpec.describe Post, type: :model do
it { is_expected.to be_mongoid_document }
endclass User
include Mongoid::Document
include Mongoid::Attributes::Dynamic
end
RSpec.describe User, type: :model do
it { is_expected.to be_dynamic_document }
endWith full timestamps.
class Log
include Mongoid::Document
include Mongoid::Timestamps
end
RSpec.describe Log, type: :model do
it { is_expected.to have_timestamps }
endWith 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 }
endWith 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) }
endWith 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 }
endclass 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') }
endIt 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') }
endIt 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
endclass 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) }
endUse 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
endCheck 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 }
endMongoid 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')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 }
endRSpec.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) }
endUse 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') }
endRSpec.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 }
endvalidate_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
endRSpec.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) }
endRSpec.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) }
endYou'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 (c) 2009-2018 Evan Sagge and Contributors.
MIT License. See LICENSE for details.