In RSpec, how do you write a custom matcher with RSpec::Matchers.define so an order-total failure explains itself?
answer
- a name plus expected arguments
- match returns truthy or falsy
- failure_message overrides the default
- chain adds fluent modifiers
- defaults come from the matcher name
basics
~20 sCall 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
Recognise a custom matcher in a spec and know it is defined with RSpec::Matchers.define and a match block.
Write one with match and failure_message, and explain the default description and message RSpec generates from the name.
Design matchers whose failure output diagnoses the domain bug, using chain and diffable where they help, and keep them to one concept.
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