shoulda-matchers Guide: Rails Model and Controller Testing

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'
end

Configure 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
end

For 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) }
end

Equivalent 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 field

Acceptance 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
end

The 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) }
end

For 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.

Read more