Reputation: 2652
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
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:
expect()
call keeps the matcher simple and lets it be all about headers, which are easy to think about since everyone knows about HTTP.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
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