RSpec Custom Matchers: Write Your Own Readable Assertions

RSpec Custom Matchers: Write Your Own Readable Assertions

When tests contain domain concepts that don't map cleanly to built-in RSpec matchers, the result is verbose assertions that reveal implementation rather than intent. Custom matchers let you express what the test means rather than how it works. A matcher like be_a_valid_invoice communicates more clearly than seven separate expect calls checking individual fields.

The Problem with Generic Assertions

# Without custom matchers — verbose, reveals implementation
it "creates a valid invoice" do
  invoice = Invoice.create(customer: customer, items: [item])

  expect(invoice.persisted?).to be true
  expect(invoice.number).to match(/\AINV-\d{6}\z/)
  expect(invoice.issued_at).to be_within(5.seconds).of(Time.current)
  expect(invoice.total).to eq(item.price)
  expect(invoice.status).to eq("draft")
  expect(invoice.customer).to eq(customer)
  expect(invoice.line_items.count).to eq(1)
end

# With custom matcher — expresses intent
it "creates a valid invoice" do
  invoice = Invoice.create(customer: customer, items: [item])
  expect(invoice).to be_a_valid_invoice_for(customer)
end

Custom Matcher with RSpec::Matchers.define

The simplest way to create a matcher:

# spec/support/matchers/invoice_matchers.rb
RSpec::Matchers.define :be_a_valid_invoice_for do |expected_customer|
  match do |invoice|
    invoice.persisted? &&
      invoice.number.match?(/\AINV-\d{6}\z/) &&
      invoice.issued_at.present? &&
      invoice.customer == expected_customer &&
      invoice.status == "draft"
  end

  failure_message do |invoice|
    problems = []
    problems << "not persisted" unless invoice.persisted?
    problems << "number '#{invoice.number}' doesn't match INV-XXXXXX format" unless invoice.number.match?(/\AINV-\d{6}\z/)
    problems << "issued_at is nil" if invoice.issued_at.nil?
    problems << "customer mismatch: expected #{expected_customer.id}, got #{invoice.customer&.id}" unless invoice.customer == expected_customer
    problems << "status '#{invoice.status}' is not 'draft'" unless invoice.status == "draft"

    "expected invoice to be valid for #{expected_customer.email}, but:\n" +
      problems.map { |p| "  - #{p}" }.join("\n")
  end

  failure_message_when_negated do |invoice|
    "expected invoice NOT to be valid for #{expected_customer.email}, but it was"
  end
end

When the match fails, you get a clear message:

expected invoice to be valid for alice@example.com, but:
  - number 'INV-ABC' doesn't match INV-XXXXXX format
  - status 'cancelled' is not 'draft'

Composable Matchers with Chainable Methods

Add chainable methods to make matchers more flexible:

RSpec::Matchers.define :have_been_charged do |expected_amount|
  chain :in_currency do |currency|
    @currency = currency
  end

  chain :for_user do |user|
    @user = user
  end

  match do |payment|
    amount_matches = payment.amount == expected_amount
    currency_matches = @currency.nil? || payment.currency == @currency
    user_matches = @user.nil? || payment.user == @user

    amount_matches && currency_matches && user_matches
  end

  failure_message do |payment|
    msg = "expected payment of #{payment.amount} #{payment.currency}"
    msg += " (user: #{payment.user.email})"
    msg += " to be a charge of #{expected_amount}"
    msg += " in #{@currency}" if @currency
    msg += " for #{@user.email}" if @user
    msg
  end
end

# Usage
expect(payment).to have_been_charged(99.99)
expect(payment).to have_been_charged(99.99).in_currency("USD")
expect(payment).to have_been_charged(99.99).in_currency("USD").for_user(alice)

The Matcher Class Approach

For complex matchers with multiple states, the class approach is cleaner:

# spec/support/matchers/render_template_matcher.rb
class RenderCorrectTemplateMatcher
  def initialize(expected_template, expected_layout)
    @expected_template = expected_template
    @expected_layout = expected_layout
  end

  def matches?(response)
    @actual_template = response.rendered_template
    @actual_layout = response.rendered_layout

    @actual_template == @expected_template &&
      @actual_layout == @expected_layout
  end

  def failure_message
    messages = []

    if @actual_template != @expected_template
      messages << "rendered template '#{@actual_template}' but expected '#{@expected_template}'"
    end

    if @actual_layout != @expected_layout
      messages << "used layout '#{@actual_layout}' but expected '#{@expected_layout}'"
    end

    messages.join("; ")
  end

  def failure_message_when_negated
    "expected not to render '#{@expected_template}' with layout '#{@expected_layout}'"
  end

  def description
    "render '#{@expected_template}' with layout '#{@expected_layout}'"
  end
end

def render_correctly(template, layout: "application")
  RenderCorrectTemplateMatcher.new(template, layout)
end

Composing Built-In Matchers

Custom matchers can delegate to other matchers:

RSpec::Matchers.define :be_a_recent_timestamp do
  match do |timestamp|
    expect(timestamp).to be_within(1.minute).of(Time.current)
  end

  failure_message do |timestamp|
    "expected #{timestamp.inspect} to be within 1 minute of now (#{Time.current.inspect})"
  end
end

RSpec::Matchers.define :be_a_valid_uuid do
  UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i

  match do |value|
    value.is_a?(String) && value.match?(UUID_PATTERN)
  end

  failure_message do |value|
    "expected #{value.inspect} to be a valid UUID"
  end
end

# Usage
expect(user.created_at).to be_a_recent_timestamp
expect(record.external_id).to be_a_valid_uuid

Negation Support

Add does_not_match? for when not_to needs different logic:

RSpec::Matchers.define :have_error_on do |field|
  match do |model|
    model.errors[field].any?
  end

  does_not_match? do |model|
    model.errors[field].none?
  end

  failure_message do |model|
    "expected #{model.class} to have errors on :#{field}, " \
      "but errors were: #{model.errors.full_messages.inspect}"
  end

  failure_message_when_negated do |model|
    "expected #{model.class} NOT to have errors on :#{field}, " \
      "but errors were: #{model.errors[field].inspect}"
  end
end

# Usage
expect(user).to have_error_on(:email)
expect(user).not_to have_error_on(:name)

Matcher Organization

For a project with many custom matchers, organize them by domain:

spec/
  support/
    matchers/
      invoice_matchers.rb
      user_matchers.rb
      api_matchers.rb
      date_matchers.rb

Load all matchers in spec/rails_helper.rb:

# spec/rails_helper.rb
Dir[Rails.root.join("spec/support/matchers/**/*.rb")].each { |f| require f }

Or load selectively:

# spec/models/invoice_spec.rb
require "support/matchers/invoice_matchers"

API Response Matchers

Custom matchers shine for JSON API testing:

RSpec::Matchers.define :be_a_paginated_response do
  match do |response|
    body = JSON.parse(response.body)
    body.key?("data") &&
      body.key?("meta") &&
      body["meta"].key?("total") &&
      body["meta"].key?("page") &&
      body["meta"].key?("per_page")
  end

  failure_message do |response|
    body = JSON.parse(response.body) rescue {}
    missing = %w[data meta].reject { |k| body.key?(k) }
    "expected response to be paginated but missing keys: #{missing.inspect}\n" \
      "Response body: #{response.body.truncate(200)}"
  end
end

RSpec::Matchers.define :include_json_error do |expected_message|
  match do |response|
    body = JSON.parse(response.body)
    errors = body["errors"] || [body["error"]]
    errors.flatten.any? { |e| e.to_s.include?(expected_message) }
  end

  failure_message do |response|
    "expected response to include error '#{expected_message}'\n" \
      "Got: #{response.body}"
  end
end

# Usage
expect(response).to be_a_paginated_response
expect(response).to include_json_error("email has already been taken")

Performance Matchers

RSpec::Matchers.define :complete_within do |expected_seconds|
  match do |block|
    start = Time.current
    block.call
    elapsed = Time.current - start
    @elapsed = elapsed
    elapsed <= expected_seconds
  end

  supports_block_expectations

  failure_message do
    "expected block to complete within #{expected_seconds}s " \
      "but took #{@elapsed.round(3)}s"
  end
end

# Usage
expect { User.search("test") }.to complete_within(0.5)

Testing the Matchers Themselves

Custom matchers should have their own tests:

# spec/support/matchers/invoice_matchers_spec.rb
RSpec.describe "be_a_valid_invoice_for matcher" do
  let(:customer) { build_stubbed(:user) }

  context "with a valid invoice" do
    let(:invoice) { build_stubbed(:invoice, :valid, customer: customer) }

    it "passes" do
      expect(invoice).to be_a_valid_invoice_for(customer)
    end
  end

  context "with a wrong customer" do
    let(:other_customer) { build_stubbed(:user) }
    let(:invoice) { build_stubbed(:invoice, :valid, customer: other_customer) }

    it "fails with a descriptive message" do
      expect {
        expect(invoice).to be_a_valid_invoice_for(customer)
      }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /customer mismatch/)
    end
  end
end

Testing your test helpers ensures they don't silently pass incorrect tests.

Summary

Custom matchers follow a simple ROI rule: write one when the same assertion appears three or more times, or when the assertion is complex enough that the failure message doesn't immediately reveal what went wrong. The goal is always the same — make the test read like a specification, not like an inspection of internal state. A test that says expect(order).to be_fulfillable communicates the intent immediately, regardless of the five checks happening inside the matcher.

Read more

Start now free