Prikso NAI
Prikso NAI

Reputation: 2652

In RSpec, how to expect that a header is included in a list of acceptable values?

I am trying to write a spec which asserts that HTTP headers from an API call are all included in a list of acceptable headers (with acceptable values, also).

I ended up writing something like this:

expect(response.headers).to all(be_included_in(acceptable_headers))

where be_included_in is a custom matcher:

RSpec::Matchers.define :be_included_in do |enumerable|
  match do |element|
    enumerable.include?(element)
  end
end

This works well for asserting that headers are all in an included range, but does not satisfy the requirement of testing their values for acceptance.

Any ideas how to do this elegantly?

Upvotes: 1

Views: 1290

Answers (2)

Dave Schweisguth
Dave Schweisguth

Reputation: 37627

Here's a solution that combines the style of your initial attempt with the idea of vetting actual headers against a Hash of Header-Name => RSpec matcher. It accomplishes the following:

  • Getting the headers from the response in the expect() call keeps the matcher simple and lets it be all about headers, which are easy to think about since everyone knows about HTTP.
  • It doesn't use negated matchers, which makes it easier to think about than a solution with multiple negations.
  • It handles a couple of cases which your double-negative solution does not and which I'll describe below.

Here's the matcher:

# I changed the first acceptable header and added a second to test that
# the matcher handles multiple acceptable headers correctly
let(:acceptable_headers) do
  {
    'Content-Type' => match(/^[a-z\-_.]+\/[a-z\-_.]+$/),
    'Content-Length' => match(/^\d+$/)
  }
end

RSpec::Matchers.define :all_be_acceptable_headers do
  match do |actual|
    actual.all? do |actual_key, actual_value|
      acceptable_headers.any? do |acceptable_key, acceptable_value|
        actual_key == acceptable_key && acceptable_value.matches?(actual_value)
      end
    end
  end

  # This is better than the default message only in that it lists acceptable headers.
  # An even better message would identify specific unacceptable headers.
  failure_message do |actual|
    "expected that #{actual} would match one of #{acceptable_headers}"
  end

end

It handles these examples which your double-negative solution also handles:

expect({ 'Content-Type' => "application/xml" }).to all_be_acceptable_headers
expect({ 'Content-Type' => "application/xml", 'Content-Length' => "123" }).to all_be_acceptable_headers
expect({ 'Content-Tape' => "application/xml" }).not_to all_be_acceptable_headers
expect({ 'Content-Type' => "not a content type" }).not_to all_be_acceptable_headers

Your double-negative solution passes if the headers: key-value pair is missing, which I suspect it should not, although that might never happen. This matcher raises NoMethodError if called on nil, which if not as user-friendly as possible is probably correct. Again, the main point is that it's just nicer to have the response not be the matcher's problem.

This matcher also handles two cases which your double-negative solution doesn't:

  • An empty header hash should pass:

    expect({}).to all_be_acceptable_headers
    
  • RSpec's include has a surprising behavior (which I discovered while figuring out why your solution didn't seem quite right): in

    expect([0]).to include(0, 1)
    

    include is treated as include_all_of, so the above fails. But in

    expect([0]).not_to include(0, 1)
    

    include is treated as include_any_of, so the above fails too!

    Because of this, your double-negative solution passes if there are multiple acceptable headers and the actual header hash has one acceptable header and one unacceptable header. This matcher handles that:

    expect({ 'Content-Type' => "not a content type", 'Content-Length' => "123" }).
      not_to all_be_acceptable_headers
    

Upvotes: 2

Prikso NAI
Prikso NAI

Reputation: 2652

It turns out this is possible with existing matchers, negated matchers, and a little bit of existential logic magic.

Here are the components:

Negated matchers:

RSpec::Matchers.define_negated_matcher :does_not_include, :include
RSpec::Matchers.alias_matcher :a_hash_not_including, :does_not_include

Accepted Headers:

let(:acceptable_headers) do
  {
    'Content-Type' => be_a(String)
  }
end

Spec "It returns only allowed headers". Logicians here will know by now that this can be rewritten as "It does not return headers which are not included in allowed headers". So there it goes:

  it 'includes only allowed headers' do
    expect(some_result).to match(
      a_hash_not_including(
        headers: a_hash_not_including(acceptable_headers)
      )
    )
  end

Upvotes: 0

Related Questions