skip to content

In RSpec, how do you write a custom matcher with RSpec::Matchers.define so an order-total failure explains itself?

level: seniorimportance: should knowfreq 30%

answer

  1. a name plus expected arguments
  2. match returns truthy or falsy
  3. failure_message overrides the default
  4. chain adds fluent modifiers
  5. defaults come from the matcher name

basics

~20 s

Call RSpec::Matchers.define :have_total_cents do |expected| ... end with a match block returning truthy or falsy, and add failure_message showing the data needed to diagnose it. RSpec supplies a default description and message from the name and arguments.

solid answer

~40 s

`RSpec::Matchers.define :have_total_cents do |expected|` defines a matcher method; the block's parameters are the arguments passed in the spec. Inside, `match { |order| order.total_cents == expected }` decides pass or fail. Without more, RSpec generates a description, "have total cents 1999", and a failure message, "expected #<Order ...> to have total cents 1999", which hides the useful part. `failure_message { |order| ... }` replaces it with what a reader needs, such as the actual total and the line items; `failure_message_when_negated` does the same for `not_to`. `chain :with_tax` adds a fluent modifier, `diffable` asks for a diff, and `supports_block_expectations` allows `expect { }`. An `expect` inside `match` is turned into a plain false unless `match(notify_expectation_failures: true)` is used. Keep matcher files in a support directory loaded before the specs.

go deeper

for a junior

Recognise a custom matcher in a spec and know it is defined with RSpec::Matchers.define and a match block.

for a middle

Write one with match and failure_message, and explain the default description and message RSpec generates from the name.

for a senior

Design matchers whose failure output diagnoses the domain bug, using chain and diffable where they help, and keep them to one concept.

for a principal

Decide when a shared matcher library pays for its global namespace and learning cost versus built-in matchers like have_attributes.

## When a custom matcher earns its place Built-in matchers answer generic questions. When a spec keeps asking a **domain** question, such as "does this order total 19.99 including tax", a custom matcher gives it a name and, more importantly, a failure message that explains the domain. The alternative, `expect(order.total_cents).to eq(1999)`, tells you two numbers differ but not which line item caused it. ## The DSL ```ruby RSpec::Matchers.define :have_total_cents do |expected| match do |order| order.total_cents == expected end failure_message do |order| lines = order.line_items.map { "#{it.sku} x#{it.qty} = #{it.subtotal_cents}" } "expected total #{expected} cents, got #{order.total_cents} from #{lines.join(", ")}" end end expect(order).to have_total_cents(1999) ``` The pieces: | Declaration | Purpose | |---|---| | `define :name do \|*expected\|` | defines the matcher method; block parameters receive the spec's arguments | | `match { \|actual\| ... }` | truthy means pass | | `match_when_negated { \|actual\| ... }` | separate logic for `not_to`, rarely needed | | `failure_message { \|actual\| ... }` | message for a failed `to` | | `failure_message_when_negated { \|actual\| ... }` | message for a failed `not_to` | | `description { ... }` | text used in generated example names | | `chain :name do \|args\| ... end` | adds a fluent modifier such as `.with_tax` | | `diffable` | prints a diff of expected and actual on failure | | `supports_block_expectations` | allows `expect { ... }.to` | ## Defaults and why to override them Without `description` or `failure_message`, RSpec builds both from the name and arguments: - The description splits the name on underscores and appends the expected values: "have total cents 1999". - The failure message is "expected" plus the inspected actual object plus "to" plus the description. For an `Order` whose `inspect` output is long, that default buries the numbers. Overriding `failure_message` to show the actual total and the line items turns a failure into a diagnosis, which is the main reason to write the matcher at all. ## Chaining ```ruby RSpec::Matchers.define :have_total_cents do |expected| chain(:with_tax) { @with_tax = true } match do |order| actual = @with_tax ? order.total_cents + order.tax_cents : order.total_cents actual == expected end end expect(order).to have_total_cents(2415).with_tax ``` `chain` returns the matcher, so modifiers compose. Chained clauses such as "with tax" are added to the generated description only when `include_chain_clauses_in_custom_matcher_descriptions` is enabled; it defaults to false in RSpec 3, and the helper file written by `rspec --init` turns it on. `chain :currency, :code` with attribute names instead of a block stores the argument in `@code` and defines a reader. ## Pitfalls 1. **Expectations inside `match`.** Writing `expect(order.total_cents).to eq(expected)` inside `match` works as a boolean, because the DSL rescues the expectation error and returns false, but the inner failure message is thrown away. `match(notify_expectation_failures: true)` lets it through. 2. **Global namespace.** `define` adds a method to `RSpec::Matchers`, visible in every example group; pick names that will not collide with other matchers or helpers. 3. **Loading.** Matcher files must be required before the specs that use them, typically from a support directory the helper file loads. 4. **Over-reach.** A matcher that checks five unrelated things fails with a message about one of them; keep one matcher per concept. ## Lighter alternatives - `have_attributes(total_cents: 1999, status: :paid)` checks several readers and reports every mismatch. - `satisfy { |order| ... }` gives a pass/fail check with a weak message. - `RSpec::Matchers.alias_matcher` and `define_negated_matcher` rename or negate existing matchers. - A plain Ruby class responding to `matches?` and `failure_message` is also a matcher, useful when it needs real internal structure.

  • What failure message does a custom matcher show if you never define failure_message?
    RSpec builds one from the inspected actual object and the generated description, for example "expected #<Order ...> to have total cents 1999". The description comes from the matcher name split on underscores plus the expected arguments, and chained clauses when that option is enabled.
  • How do you get `expect(order).not_to have_total_cents(0)` to print a helpful message?
    Define `failure_message_when_negated`, which RSpec uses when a `not_to` expectation fails. Without it RSpec generates "expected ... not to have total cents 0". If the negative check needs different logic, `match_when_negated` defines it.

saying these in an interview costs you the question

  • a custom matcher must subclass an RSpec base class
  • the match block must raise to signal a failure
  • an expect inside match reports its own failure message by default
  • a matcher from RSpec::Matchers.define is only visible in its own file
  • the generated failure message is usually good enough for domain objects