Read more

RSpec: Composing a custom matcher from existing matchers

Henning Koch
November 24, 2023Software engineer at makandra GmbH

When you find similar groups of expect calls in your tests, you can improve readability by extracting the group into its own matcher. RSpec makes this easy by allowing matchers to call other matchers.

Example

Illustration online protection

Rails professionals since 2007

Our laser focus on a single technology has made us a leader in this space. Need help?

  • We build a solid first version of your product
  • We train your development team
  • We rescue your project in trouble
Read more Show archive.org snapshot

The following test checks that two variables foo and bar (1) have no lowercase characters and (2) end with an exclamation mark:

expect(foo).to_not match(/[a-z]/)
expect(foo).to end_with('!')

expect(bar).to_not match(/[a-z]/)
expect(bar).to end_with('!')

We can extract the repeated matcher chains into a custom matcher called be_shouting:

expect(foo).to be_shouting
expect(bar).to be_shouting

Instead of re-implementing the match and end_with matchers, our new matcher can simply use expect:

RSpec::Matchers.define :be_shouting do
  
  match do |string|
    expect(string).to_not match(/[a-z]/)
    expect(string).to end_with('!')
    true
  end
  
end

Tip

It's a good habit to always end a composed matcher in true to ensure that it returns a truthy value. E.g. by using a condition as the last expression you may otherwise implement a matcher that sometimes fails:

match do |string|
  if some_condition
    expect(string).to_not match(/[a-z]/)
    expect(string).to end_with('!')
  end
end

Revealing more details in failure messages

When an embedded matcher fails (like end_with('!') in the example), its failure message will be overridden by the custom matcher:

Failure/Error: expect("FOO").to be_shouting
  expected "FOO" to be shouting

You may prefer to see the exact message produced by the failing matcher:

Failure/Error: expect("FOO").to be_shouting
  expected "FOO" to end with "!"

You can do this by passing a { notify_expectation_failures: true } option to your custom matcher's match block:

RSpec::Matchers.define :be_shouting do
  
  match(notify_expectation_failures: true) do |string|
    expect(string).to_not match(/[a-z]/)
    expect(string).to end_with('!')
    true
  end
  
end
Henning Koch
November 24, 2023Software engineer at makandra GmbH
Posted by Henning Koch to makandra dev (2023-11-24 13:44)