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)
endCustom 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
endWhen 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)
endComposing 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_uuidNegation 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.rbLoad 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
endTesting 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.