shoulda-matchers Guide: Rails Model and Controller Testing
shoulda-matchers provides a collection of one-liner matchers for testing common Rails patterns: validations, associations, and controller behavior. Instead of writing multi-line setup to test validates :name, presence: true, you write one line: it { is_expected.to validate_presence_of(:name) }.
This guide covers setup, all major matchers, and best practices for model and controller testing.
Installation
Add to your Gemfile:
group :test do
gem 'shoulda-matchers'
endConfigure it in spec/rails_helper.rb or a support file:
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
endFor Minitest, change :rspec to :minitest.
That's it. No additional requires — the matchers are automatically available in your specs.
Validation Matchers
Presence
RSpec.describe User, type: :model do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:email) }
endEquivalent to testing that validates :name, presence: true is in your model.
Uniqueness
it { is_expected.to validate_uniqueness_of(:email) }
# Case insensitive
it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
# Scoped uniqueness
it { is_expected.to validate_uniqueness_of(:username).scoped_to(:organization_id) }Note: The uniqueness matcher requires a persisted record to work correctly. If you use FactoryBot, ensure your factory can create successfully.
Length
it { is_expected.to validate_length_of(:username).is_at_least(3) }
it { is_expected.to validate_length_of(:username).is_at_most(50) }
it { is_expected.to validate_length_of(:bio).is_at_most(500) }
it { is_expected.to validate_length_of(:pin).is_equal_to(4) }
# Combining constraints
it { is_expected.to validate_length_of(:name).is_at_least(2).is_at_most(100) }Numericality
it { is_expected.to validate_numericality_of(:age) }
it { is_expected.to validate_numericality_of(:age).only_integer }
it { is_expected.to validate_numericality_of(:age).is_greater_than(0) }
it { is_expected.to validate_numericality_of(:age).is_less_than_or_equal_to(150) }
it { is_expected.to validate_numericality_of(:score).is_greater_than_or_equal_to(0) }Inclusion and Exclusion
it { is_expected.to validate_inclusion_of(:status).in_array(%w[active inactive suspended]) }
it { is_expected.to validate_inclusion_of(:age).in_range(18..65) }
it { is_expected.to validate_exclusion_of(:username).in_array(%w[admin root superuser]) }Format
it { is_expected.to validate_format_of(:email).with('alice@example.com') }
it { is_expected.to validate_format_of(:email).not_with('invalid-email') }
# Custom regex
it { is_expected.to allow_value('alice@example.com').for(:email) }
it { is_expected.not_to allow_value('not-an-email').for(:email) }allow_value
allow_value is a flexible matcher for testing what values a field accepts:
it { is_expected.to allow_value('admin', 'member', 'guest').for(:role) }
it { is_expected.not_to allow_value('superuser', '').for(:role) }
it { is_expected.to allow_value(nil).for(:bio) } # optional fieldAcceptance and Confirmation
it { is_expected.to validate_acceptance_of(:terms_of_service) }
it { is_expected.to validate_confirmation_of(:password) }Association Matchers
belongs_to
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:organization).optional } # optional: true
it { is_expected.to belong_to(:author).class_name('User') }has_many
it { is_expected.to have_many(:posts) }
it { is_expected.to have_many(:posts).dependent(:destroy) }
it { is_expected.to have_many(:comments).through(:posts) }
it { is_expected.to have_many(:followers).class_name('User') }has_one
it { is_expected.to have_one(:profile) }
it { is_expected.to have_one(:profile).dependent(:destroy) }has_and_belongs_to_many
it { is_expected.to have_and_belongs_to_many(:tags) }Database Matchers
shoulda-matchers also includes matchers that verify the database schema:
it { is_expected.to have_db_column(:email).of_type(:string).with_options(null: false) }
it { is_expected.to have_db_column(:active).of_type(:boolean).with_options(default: true) }
it { is_expected.to have_db_index(:email).unique }
it { is_expected.to have_db_index([:user_id, :post_id]).unique }These check the database schema directly, not the model — useful for ensuring migrations are applied correctly.
Callback Matchers
it { is_expected.to callback(:send_welcome_email).after(:create) }
it { is_expected.to callback(:normalize_name).before(:validation) }
it { is_expected.to callback(:clean_up).after(:destroy) }Full Model Spec Example
Here's how shoulda-matchers fits into a complete model spec:
require 'rails_helper'
RSpec.describe User, type: :model do
# Validations
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
it { is_expected.to validate_length_of(:name).is_at_least(2).is_at_most(100) }
it { is_expected.to validate_inclusion_of(:role).in_array(%w[admin member guest]) }
# Associations
it { is_expected.to belong_to(:organization) }
it { is_expected.to have_many(:posts).dependent(:destroy) }
it { is_expected.to have_one(:profile).dependent(:destroy) }
# Database
it { is_expected.to have_db_index(:email).unique }
it { is_expected.to have_db_column(:active).of_type(:boolean) }
# Custom behavior — use regular RSpec here
describe '#full_name' do
it 'combines first and last name' do
user = build(:user, first_name: 'Alice', last_name: 'Smith')
expect(user.full_name).to eq('Alice Smith')
end
end
describe '.active' do
it 'returns only active users' do
active = create(:user, active: true)
inactive = create(:user, active: false)
expect(User.active).to include(active)
expect(User.active).not_to include(inactive)
end
end
endThe one-liners handle all structural checks. Regular RSpec handles behavior.
Subject Setup
shoulda-matchers uses the subject of the spec. For model specs with type: :model, the subject is automatically described_class.new. If your validations require context (like a factory-built object), override subject:
RSpec.describe Post, type: :model do
subject { build(:post) }
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_uniqueness_of(:slug).scoped_to(:user_id) }
endFor validate_uniqueness_of, you may need a persisted record:
subject { create(:post) }
it { is_expected.to validate_uniqueness_of(:slug) }What shoulda-matchers Doesn't Cover
shoulda-matchers tests Rails conventions — it verifies that your model has specific validation rules. It doesn't test:
- Custom validation logic — write RSpec examples for these
- Callbacks with complex behavior — test the outcomes, not the callbacks
- Scopes — use regular RSpec with database records
- Business logic — use unit tests
Use shoulda-matchers for the boilerplate checks. Use RSpec for anything that requires thinking.
Common Gotchas
Uniqueness matcher needs a record in the database. If the table is empty, the matcher can't verify uniqueness. Use create or subject { create(:model) }.
Optional associations. By default, belong_to assumes the association is required (Rails 5+). If it's optional, add .optional:
it { is_expected.to belong_to(:category).optional }allow_value vs validate_inclusion_of. allow_value tests individual accepted/rejected values. validate_inclusion_of requires specifying the complete array. Use allow_value for spot-checking, validate_inclusion_of when you want to verify the exact allowed set.
shoulda-matchers and polymorphic associations. Polymorphic belongs_to requires extra configuration — check the gem's documentation for the current syntax.
Summary
shoulda-matchers eliminates boilerplate from model specs. Validations and associations that would otherwise take 3-5 lines each become single-line assertions. This keeps model specs short and readable, freeing you to focus the more complex test cases on actual behavior.