Can I use a built-in RSpec matcher in a custom matcher?
Asked Answered
A

4

12

I have the following expectation in a feature spec (pretty low-level but still necessary):

expect(Addressable::URI.parse(current_url).query_values).to include(
  'some => 'value',
  'some_other' => String
)

Note the second query value is a fuzzy match because I just want to make sure it's there but I can't be more specific about it.

I'd like to extract this into a custom matcher. I started with:

RSpec::Matchers.define :have_query_params do |expected_params|
  match do |url|
    Addressable::URI.parse(url).query_values == expected_params
  end
end

but this means I cannot pass {'some_other' => String} in there. To keep using a fuzzy match, I'd have to use the include matcher in my custom matcher.

However, anything within RSpec::Matchers::BuiltIn is marked as private API, and Include specifically is documented as:

# Provides the implementation for `include`.
# Not intended to be instantiated directly.

So, my question is: Is using a built-in matcher within a custom matcher supported in RSpec? How would I do that?

Atkinson answered 17/8, 2016 at 8:40 Comment(0)
H
9

RSpec::Matchers appears to be a supported API (its rdoc doesn't say otherwise), so you can write your matcher in Ruby instead of in the matcher DSL (which is supported; see the second paragraph of the custom matcher documentation) and use RSpec::Matchers#include like this:

spec/support/matchers.rb

module My
  module Matchers
    def have_query_params(expected)
      HasQueryParams.new expected
    end

    class HasQueryParams
      include RSpec::Matchers

      def initialize(expected)
        @expected = expected
      end

      def matches?(url)
        actual = Addressable::URI.parse(url).query_values
        @matcher = include @expected
        @matcher.matches? actual
      end

      def failure_message
        @matcher.failure_message
      end

    end
  end
end

spec/support/matcher_spec.rb

include My::Matchers

describe My::Matchers::HasQueryParams do
  it "matches" do
    expect("http://example.com?a=1&b=2").to have_query_params('a' => '1', 'b' => '2')
  end
end
Holpen answered 17/8, 2016 at 12:43 Comment(2)
Make sense. I'll give it a try.Atkinson
At first, I was running into the problem where RSpec is inferring from the matcher name that the URL implements has_query_params? but that was only because I forgot the include. Thanks for pinging me, I had some problems with my env in the meantime.Atkinson
M
3

Yes, you can call built-in rspec matchers from within a custom matcher. Put another way, you can use the normal Rspec DSL instead of pure Ruby when writing your matcher. Check out this gist (not my gist, but it helped me!).

I've got a really complex controller with a tabbed interface where the defined and selected tab depend on the state of the model instance. I needed to test tab setup in every state of the :new, :create, :edit and :update actions. So I wrote these matchers:

require "rspec/expectations"

RSpec::Matchers.define :define_the_review_tabs do
  match do
    expect(assigns(:roles         )).to be_a_kind_of(Array)
    expect(assigns(:creators      )).to be_a_kind_of(ActiveRecord::Relation)
    expect(assigns(:works         )).to be_a_kind_of(Array)

    expect(assigns(:available_tabs)).to include("post-new-work")
    expect(assigns(:available_tabs)).to include("post-choose-work")
  end

  match_when_negated do
    expect(assigns(:roles         )).to_not be_a_kind_of(Array)
    expect(assigns(:creators      )).to_not be_a_kind_of(ActiveRecord::Relation)
    expect(assigns(:works         )).to_not be_a_kind_of(Array)

    expect(assigns(:available_tabs)).to_not include("post-new-work")
    expect(assigns(:available_tabs)).to_not include("post-choose-work")
  end

  failure_message do
    "expected to set up the review tabs, but did not"
  end

  failure_message_when_negated do
    "expected not to set up review tabs, but they did"
  end
end

RSpec::Matchers.define :define_the_standalone_tab do
  match do
    expect(assigns(:available_tabs)).to include("post-standalone")
  end

  match_when_negated do
    expect(assigns(:available_tabs)).to_not include("post-standalone")
  end

  failure_message do
    "expected to set up the standalone tab, but did not"
  end

  failure_message_when_negated do
    "expected not to set up standalone tab, but they did"
  end
end

RSpec::Matchers.define :define_only_the_review_tabs do
  match do
    expect(assigns).to     define_the_review_tabs
    expect(assigns).to_not define_the_standalone_tab
    expect(assigns(:selected_tab)).to eq(@selected) if @selected
  end

  chain :and_select do |selected|
    @selected = selected
  end

  failure_message do
    if @selected
      "expected to set up only the review tabs and select #{@selected}, but did not"
    else
      "expected to set up only the review tabs, but did not"
    end
  end
end

RSpec::Matchers.define :define_only_the_standalone_tab do
  match do
    expect(assigns).to     define_the_standalone_tab
    expect(assigns).to_not define_the_review_tabs
    expect(assigns(:selected_tab)).to eq("post-standalone")
  end

  failure_message do
    "expected to set up only the standalone tab, but did not"
  end
end

RSpec::Matchers.define :define_all_tabs do
  match do
    expect(assigns).to define_the_review_tabs
    expect(assigns).to define_the_standalone_tab
    expect(assigns(:selected_tab)).to eq(@selected) if @selected
  end

  chain :and_select do |selected|
    @selected = selected
  end

  failure_message do
    if @selected
      "expected to set up all three tabs and select #{@selected}, but did not"
    else
      "expected to set up all three tabs, but did not"
    end
  end
end

And am using them like so:

should define_all_tabs.and_select("post-choose-work")
should define_all_tabs.and_select("post-standalone")
should define_only_the_standalone_tab
should define_only_the_review_tabs.and_select("post-choose-work")
should define_only_the_review_tabs.and_select("post-new-work")

Super-awesome to be able to just take several chunks of repeated expectations and roll them up into a set of custom matchers without having to write the matchers in pure Ruby.

This saves me dozens of lines of code, makes my tests more expressive, and allows me to change things in one place if the logic for populating these tabs changes.

Also note that you have access in your custom matcher to methods/variables such as assigns and controller so you don't need to pass them in explicitly.

Finally, I could have defined these matchers inline in the spec, but I chose to put them in spec/support/matchers/controllers/posts_controller_matchers.rb

Marigolda answered 20/4, 2018 at 23:48 Comment(1)
This always felt awkward to me, but if Myron Marston supports it... Though it's been quite a while, and I'm wondering/hoping for better & more supported/documented ways of reusing/overriding builtins.Samiel
W
0

You can use the matcher DSL instead of writing your own Matcher class. It is a bit simpler.

RSpec::Matchers.define :have_query_params do |expected|
  match do |actual|
    # your code
    RSpec::Matchers::BuiltIn::Include.new(expected).matches?(actual)
  end
end
Wrathful answered 30/11, 2021 at 20:3 Comment(0)
Y
0

I ended up just catching RSpec::Expectations::ExpectationNotMetError in my match block so I could then set a better error message. So I did something like:

RSpec.configure do |config|
  RSpec::Matchers.define :custom_string_eq do |some_string|
    fm = nil
    match do |passed_string|
      expect(passed_string).to eq("#{some_string} EXTRA")
      true
    rescue RSpec::Expectations::ExpectationNotMetError => e
      fm = e.message
      fm += e.backtrace.find { |b| b.include?(__FILE__) }
      false
    end

    failure_message do |yara_file|
      fm || 'Unknown Error'
    end
  end
end

Some test

RSpec.describe SomeClass do
  it 'should pass custom matcher' do
    expect('test EXTRA').to custom_string_eq('test')
  end
  it 'should not pass custom matcher' do
    expect('test').to custom_string_eq('test')
  end
end

Then in my test I at least get something kinda helpful

  Failures:

  1) SomeClass should not pass custom matcher
     Failure/Error: expect('test').to custom_string_eq('test')
     
       expected: "test EXTRA"
            got: "test"
     
       (compared using ==)
       .../spec/spec_helper.rb:18:in `block (3 levels) in <top (required)>'
     # ./spec/unit/some_file_spec.rb:56:in `block (2 levels) in <top (required)>'
Yolondayon answered 5/5, 2023 at 14:18 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.