What Shoulda Matchers Is Actually Doing For You

If you’ve worked on a Rails app, you’ve probably written or seen something like:

it { is_expected.to validate_presence_of(:email) }
it { is_expected.to have_many(:orders).dependent(:destroy) }

It feels a bit like a magic trick: one short line, and a lot of behavior is being tested.

In this post, we’ll peek behind the curtain and see what these matchers are actually doing for you.

More than shorter specs

Those one-liners aren’t just shorter, they’re less fragile. Each matcher:

  • Builds an isolated setup focused only on the behavior under test
  • Encapsulates battle-tested patterns for exercising Rails features
  • Makes your specs read like documentation and creates self-contained tests

That third point is easy to miss, so let’s make it concrete.

Imagine you wrote a presence test that happens to reuse some factory or setup. Later, you add a new validation to the model. Suddenly:

  • Your old “valid” setup is now invalid, or
  • An example that used to fail now passes for the wrong reason

Presence validation in practice

Say we want to test that User requires an email.

Without Shoulda Matchers

RSpec.describe User, type: :model do
  it "is invalid without an email" do
    user = User.new(email: nil)

    expect(user).not_to be_valid
    expect(user.errors[:email]).to include("can't be blank")
  end
end

This works, but:

  • It’s verbose
  • It couples the test to specific error messages
  • You’ll repeat this pattern for every attribute in every model
  • If you add more validations later, this setup might fail for unrelated reasons

With Shoulda Matchers

RSpec.describe User, type: :model do
  it { is_expected.to validate_presence_of(:email) }
end

Now the spec says exactly what matters:

“User validates presence of email.”

The “magic” is that the matcher hides the mechanics (building the record, calling valid?, checking errors) and focuses on the behavior you care about. As validations evolve, the matcher’s own setup logic is less likely to silently drift away from your intent.

Under the hood: it’s just Ruby

Matchers can look mysterious, but they’re just Ruby objects that respond to the matches? method and know how to describe failures.

Conceptually:

expect(user).to matcher
# becomes something like:
matcher.matches?(user)

shoulda-matchers uses that extension point to embed Rails knowledge into each matcher:

  • How to trigger validations correctly
  • How to introspect associations
  • How to avoid common false positives
  • How to construct focused setups that don’t accidentally depend on unrelated behavior

So that one line is really calling into a well-designed object that knows how to exercise that slice of Rails for you.

validate_uniqueness_of: catching subtle bugs

Uniqueness is where manually written tests tests often mislead you. A typical spec might look like:

it "is invalid when another user has the same email" do
  User.create!(email: "test@example.com")
  user = User.new(email: "test@example.com")

  expect(user).not_to be_valid
end

This passes. But imagine you later add validates :name, presence: true. Now the test still passes, but because name is blank, not because the email is duplicated. Your uniqueness validation could be completely broken and this test wouldn’t tell you.

With shoulda-matchers:

it { is_expected.to validate_uniqueness_of(:email).case_insensitive }

That one expectation:

  • Creates only strictly necessary records
  • Checks case sensitivity when requested
  • Focuses on the uniqueness behavior itself
  • Won’t be “accidentally” satisfied by unrelated validations
  • Knows about edge cases and provides helpful error messages when your test setup or validation logic has issues

Because shoulda-matchers builds its own minimal setup focused on the behavior under test, your spec is less likely to accidentally start passing or failing due to unrelated model changes. You still need higher-level tests for real-world flows, but this gives you a focused unit check with less brittleness.

have_many and friends: specs as documentation

Associations work the same way. Compare:

it { is_expected.to have_many(:orders).dependent(:destroy) }

with the manual version:

it "has many orders with dependent destroy" do
  association = User.reflect_on_association(:orders)

  expect(association.macro).to eq(:has_many)
  expect(association.options[:dependent]).to eq(:destroy)
end

Both assert the same thing, but the matcher:

  • Reads like a sentence in your domain
  • Hides reflection details
  • Encourages a consistent style across models

A block of matchers at the top of a spec file often gives you an instant overview of a model’s contract:

RSpec.describe User, type: :model do
  it { is_expected.to validate_presence_of(:email) }
  it { is_expected.to validate_uniqueness_of(:email).case_insensitive }

  it { is_expected.to have_many(:orders).dependent(:destroy) }
  it { is_expected.to belong_to(:account) }
end

Skimming that tells you:

  • What must be present
  • What must be unique
  • How the record relates to others

Your tests double as living documentation and because each matcher targets one piece of behavior, they tend to age more gracefully as the model grows.

When not to use shoulda-matchers

shoulda-matchers shines for Rails-y behavior, but it’s not the right tool for everything.

You’ll usually prefer explicit specs when:

  • You’re testing business logic or product rules
  • You’re describing multi-step flows or user behavior
  • A matcher would hide more than it reveals

A simple rule of thumb:

Will Future Me understand this expectation faster with or without a matcher?

If a plain expectation tells the story better, use that instead.

Wrapping up

When you write:

it { is_expected.to validate_presence_of(:email) }

you’re not just saving a few lines. You’re:

  • Reusing a well-tested pattern for exercising Rails behavior
  • Turning your specs into readable documentation
  • Making tests less fragile as your models evolve
  • Sharing a common testing language with your team

shoulda-matchers is a small, sharp tool that helps your tests say exactly what you mean, even as the code underneath continues to change. Once you understand the trick, it stops being magic and starts being part of your everyday toolkit.

About thoughtbot

We've been helping engineering teams deliver exceptional products for over 20 years. Our designers, developers, and product managers work closely with teams to solve your toughest software challenges through collaborative design and development. Learn more about us.