rubyrspec

Is there a way to set up an expectation for a hash but being "indifferent" about whether keys are symbols or strings?


Working with rspec-expectations 3.13.3 and have code such as:

def methname(**kwargs)
  # let's assume this method doesn't mind whether the kwargs are keyed by symbols or strings
  p(received: kwargs)
end

with a test such as

expect(something).to receive(:methname).with(hash_including(what_ever: "else"))

But in some cases the keyword arguments are being created as strings, not symbols, so that fails and I have to do

expect(something).to receive(:methname).with(hash_including("what_ever" => "else"))

I can get this right in each spec, but it would be cleaner for me if I could match it indifferently -

# desired code
expect(something).to receive(:methname).with(hash_including_indifferently(what_ever: "else"))

is there a way to already do this?


Solution

  • Is there a way to already do this?

    There is no built in matcher that supports this but we can use built in matchers to accomplish this goal (for single use) or create our own matcher (multi-use).

    Solution

    If you need this frequently or need it to be more flexible in what is or is not included in the Hash, your best bet is to define your own matcher. See: Custom Matchers.

    For Example this will work:

    RSpec::Matchers.define_negated_matcher :not_include, :include
    RSpec::Matchers.define :indifferently_include do |expected| 
      match(:notify_expectation_failures => true) do |actual|
        fit_pattern = expected.map do |k,v| 
            include(k.to_s).and(not_include(k.to_sym)).or(
              include(k.to_sym).and(not_include(k.to_s))
            ).and(
              include(k.to_s => v).or(include(k.to_sym => v))
            )
          end.reduce(&:and)
        expect(actual).to fit_pattern
      end
    end
    RSpec::Matchers.alias_matcher :hash_including_indifferently, :indifferently_include
    
    

    Usage:

    expect(something).to receive(:methname).with(hash_including_indifferently({"what_ever" => "else", another_key: "too"}))
    

    Methodology

    If you want to test in such a way as to allow kwargs to contain either of {"what_ever" => "else"} or {what_ever: "else"} you can test as follows:

    expect(something).to receive(:methname) do |h| 
      expect(h).to  include(what_ever: "else").or include("what_ever" => "else")
    end
    

    because receive with a block will yield the arguments to the block and you can test them explicitly using a Compound Expectation.

    However if the Hash contains both :what_ever and "what_ever" as keys as long as one of them has the value "else" this test will pass. Given that your intent is for this test to be indifferent about whether the key is a String or Symbol, the keys would need to be uniquely indifferent as well, so you may want to test as:

    RSpec::Matchers.define_negated_matcher :not_include, :include
    
    expect(something).to receive(:methname) do |h| 
      expect(h).to  include("what_ever").and(
                      not_include(:what_ever)
                    ).or(
                      include(:what_ever).and(
                        not_include("what_ever")
                      )
                    ).and( 
                      include("what_ever" => "else").or(
                        include(what_ever: "else")
                      )
                    )
    end
    

    This will prevent ambiguity for any Hash that might contain both "what_ever" and :what_ever keys, while ensuring that one of them is present with the value "else".