New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
New matcher: have_attributes
#571
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
Feature: have_attributes matcher | ||
|
||
Use the have_attributes matcher to specify that | ||
an object's attributes match the expected attributes: | ||
|
||
```ruby | ||
Person = Struct.new(:name, :age) | ||
person = Person.new("Jim", 32) | ||
|
||
expect(person).to have_attributes(:name => "Jim", :age => 32) | ||
expect(person).to have_attributes(:name => a_string_starting_with("J"), :age => (a_value > 30) ) | ||
``` | ||
|
||
The matcher will fail if actual doesn't respond to any of the expected attributes: | ||
|
||
```ruby | ||
expect(person).to have_attributes(:name => "Jim", :color => 'red') | ||
``` | ||
|
||
Scenario: basic usage | ||
Given a file named "basic_have_attributes_matcher_spec.rb" with: | ||
"""ruby | ||
Person = Struct.new(:name, :age) | ||
|
||
RSpec.describe Person.new("Jim", 32) do | ||
it { is_expected.to have_attributes(:name => "Jim") } | ||
it { is_expected.to have_attributes(:name => a_string_starting_with("J") ) } | ||
it { is_expected.to have_attributes(:age => 32) } | ||
it { is_expected.to have_attributes(:age => (a_value > 30) ) } | ||
it { is_expected.to have_attributes(:name => "Jim", :age => 32) } | ||
it { is_expected.to have_attributes(:name => a_string_starting_with("J"), :age => (a_value > 30) ) } | ||
it { is_expected.not_to have_attributes(:name => "Bob") } | ||
it { is_expected.not_to have_attributes(:age => 10) } | ||
it { is_expected.not_to have_attributes(:age => (a_value < 30) ) } | ||
|
||
# deliberate failures | ||
it { is_expected.to have_attributes(:name => "Bob") } | ||
it { is_expected.to have_attributes(:age => 10) } | ||
|
||
# fails if any of the attributes don't match | ||
it { is_expected.to have_attributes(:name => "Bob", :age => 32) } | ||
it { is_expected.to have_attributes(:name => "Jim", :age => 10) } | ||
it { is_expected.to have_attributes(:name => "Bob", :age => 10) } | ||
end | ||
""" | ||
When I run `rspec basic_have_attributes_matcher_spec.rb` | ||
Then the output should contain "14 examples, 5 failures" |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -560,6 +560,30 @@ def exist(*args) | |
alias_matcher :an_object_existing, :exist | ||
alias_matcher :existing, :exist | ||
|
||
# Passes if actual's attribute values match the expected attributes hash. | ||
# This works no matter how you define your attribute readers. | ||
# | ||
# @example | ||
# | ||
# Person = Struct.new(:name, :age) | ||
# person = Person.new("Bob", 32) | ||
# | ||
# expect(person).to have_attributes(:name => "Bob", :age => 32) | ||
# expect(person).to have_attributes(:name => a_string_starting_with("B"), :age => (a_value > 30) ) | ||
# | ||
# @note It will fail if actual doesn't respond to any of the expected attributes. | ||
# | ||
# @example | ||
# | ||
# expect(person).to have_attributes(:color => "red") | ||
# | ||
# rubocop:disable Style/PredicateName | ||
def have_attributes(expected) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can disable the offense report by RuboCop by adding an annotation comment as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thanks @yujinakayama ! |
||
BuiltIn::HaveAttributes.new(expected) | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should add an aliased form of this matcher for use composed expressions: expect { |probe|
foo(&probe)
}.to yield_with_args(an_object_having_attributes(:name => "Jane", :age => 40)) I'm not sure what the best phrasing for the alias is, though. Most of our other aliases use the What do others think? /cc @samphippen @xaviershay @JonRowe @soulcutter @cupakromer There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @myronmarston what about:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @yelled3 -- I'm not following...can you show example specs of what you are proposing? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i was simply suggesting possible aliases: expect(person).to an_object_with_attributes(:name => "Myron", :age => 32)
expect(person).to an_object_with_attribute(:name => "Myron")
expect { |probe|
foo(&probe)
}.to yield_with_args(an_object_having_attribute(:age => (a_value > 18)))
expect(person).to with_attributes(:name => "Myron", :age => 32)
expect(person).to with_an_attribute(:name => "Myron")
expect { |probe|
foo(&probe)
}.to yield_with_args(with_attributes(:name => "Jane", :age => 40)) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think @JonRowe, what do you think? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Agree, but just There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
if consistency means doing: alias_matcher :an_object_ORIG_NAME, :ORIG_NAME than by looking at other alias_matcher :an_object_existing, :exist
alias_matcher :an_object_matching, :match
alias_matcher :an_object_responding_to, :respond_to
alias_matcher :an_object_satisfying, :satisfy so I vote for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That's not what I mean by consistency. (In fact, I don't think we have any matcher aliases that use that form). I mean that most of our matcher aliases use an
It's easier to guess what the matcher alias will be when we stick with a common convention like this.
Those are all examples of
I think I'm leaning towards Thoughts from @samphippen @soulcutter @cupakromer @xaviershay @yujinakayama ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nope, I think the alias is the last thing needed. |
||
# rubocop:enable Style/PredicateName | ||
alias_matcher :an_object_having_attributes, :have_attributes | ||
|
||
# Passes if actual includes expected. This works for | ||
# collections and Strings. You can also pass in multiple args | ||
# and it will only pass if all args are found in collection. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
module RSpec | ||
module Matchers | ||
module BuiltIn | ||
# @api private | ||
# Provides the implementation for `have_attributes`. | ||
# Not intended to be instantiated directly. | ||
class HaveAttributes < BaseMatcher | ||
# @private | ||
attr_reader :respond_to_failed | ||
|
||
def initialize(expected) | ||
@expected = expected | ||
@respond_to_failed = false | ||
end | ||
|
||
# @api private | ||
# @return [Boolean] | ||
def matches?(actual) | ||
@actual = actual | ||
return false unless respond_to_attributes? | ||
perform_match(:all?) | ||
end | ||
|
||
# @api private | ||
# @return [Boolean] | ||
def does_not_match?(actual) | ||
@actual = actual | ||
return false unless respond_to_attributes? | ||
perform_match(:none?) | ||
end | ||
|
||
# @api private | ||
# @return [String] | ||
def description | ||
described_items = surface_descriptions_in(expected) | ||
improve_hash_formatting "have attributes #{described_items.inspect}" | ||
end | ||
|
||
# @api private | ||
# @return [String] | ||
def failure_message | ||
respond_to_failure_message_or { super } | ||
end | ||
|
||
# @api private | ||
# @return [String] | ||
def failure_message_when_negated | ||
respond_to_failure_message_or { super } | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I noticed in the build that the diff is odd: https://travis-ci.org/rspec/rspec-expectations/jobs/27226588#L275 If you have an idea for how to fix that, feel free -- otherwise, my advice is to make it not diffable for now. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's odd because Structs are enumerable and the differ is interpreting it as an array. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. solved it, by doing: module Composable
def surface_descriptions_in(item)
#...
elsif Struct === item
Hash[surface_descriptions_in(item.each_pair.to_a)] WDYT? @myronmarston @JonRowe /cc There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. see: yelled3@046f4d8 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not a fan of special-casing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sounds good. I thought, that was a specific issue with Struct... we can deal with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (See #576) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that was fast :-) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This and def failure_message
respond_to_failure_message_or { super }
end
def failure_message_when_negated
respond_to_failure_message_or { super }
end
private
def respond_to_failure_message_or
if respond_to_failed
respond_to_matcher.failure_message
else
improve_hash_formatting(yield)
end
end There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done :-) |
||
|
||
private | ||
|
||
def perform_match(predicate) | ||
expected.__send__(predicate) do |attribute_key, attribute_value| | ||
actual_has_attribute?(attribute_key, attribute_value) | ||
end | ||
end | ||
|
||
def actual_has_attribute?(attribute_key, attribute_value) | ||
actual_value = actual.__send__(attribute_key) | ||
values_match?(attribute_value, actual_value) | ||
end | ||
|
||
def respond_to_attributes? | ||
matches = respond_to_matcher.matches?(actual) | ||
@respond_to_failed = !matches | ||
matches | ||
end | ||
|
||
def respond_to_matcher | ||
@respond_to_matcher ||= RespondTo.new(*expected.keys).with(0).arguments | ||
end | ||
|
||
def respond_to_failure_message_or | ||
if respond_to_failed | ||
respond_to_matcher.failure_message | ||
else | ||
improve_hash_formatting(yield) | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -212,6 +212,14 @@ module RSpec | |
expect(existing).to be_aliased_to(exist).with_description("existing") | ||
end | ||
|
||
specify do | ||
expect( | ||
an_object_having_attributes(:age => 32) | ||
).to be_aliased_to( | ||
have_attributes(:age => 32) | ||
).with_description("an object having attributes {:age => 32}") | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @myronmarston added the alias |
||
|
||
specify do | ||
expect( | ||
a_string_including("a") | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
RSpec.describe "#have_attributes matcher" do | ||
|
||
Person = Struct.new(:name, :age) | ||
|
||
class Person | ||
def parent(parent_name) | ||
@parent = parent_name | ||
end | ||
end | ||
|
||
let(:wrong_name) { "Wrong Name" } | ||
let(:wrong_age) { 11 } | ||
|
||
let(:correct_name) { "Correct name" } | ||
let(:correct_age) { 33 } | ||
|
||
let(:person) { Person.new(correct_name, correct_age) } | ||
|
||
it "is not diffable" do | ||
expect(have_attributes(:age => correct_age)).to_not be_diffable | ||
end | ||
|
||
describe "expect(...).to have_attributes(with_one_attribute)" do | ||
|
||
it_behaves_like "an RSpec matcher", :valid_value => Person.new("Correct name", 33), :invalid_value => Person.new("Wrong Name", 11) do | ||
let(:matcher) { have_attributes(:name => "Correct name") } | ||
end | ||
|
||
it "passes if target has the provided attributes" do | ||
expect(person).to have_attributes(:name => correct_name) | ||
end | ||
|
||
it "fails if target does not have any of the expected attributes" do | ||
expect { | ||
expect(person).to have_attributes(:name => wrong_name) | ||
}.to fail_matching(%r|expected #{object_inspect person} to have attributes #{hash_inspect :name => wrong_name}|) | ||
end | ||
|
||
it "fails if target does not responds to any of the attributes" do | ||
expect { | ||
expect(person).to have_attributes(:color => 'red') | ||
}.to fail_matching("expected #{object_inspect person} to respond to :color") | ||
end | ||
|
||
it "fails if target responds to the attribute but requires arguments" do | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
see bellow, for all uses cases |
||
expect { | ||
expect(person).to have_attributes(:parent => 'Billy') | ||
}.to fail_matching("expected #{object_inspect person} to respond to :parent with 0 arguments") | ||
end | ||
|
||
describe "expect(...).to have_attributes(key => matcher)" do | ||
|
||
it "passes when the matchers match" do | ||
expect(person).to have_attributes(:age => (a_value > 30)) | ||
end | ||
|
||
it 'provides a description' do | ||
description = have_attributes(:age => (a_value > 30)).description | ||
expect(description).to eq("have attributes {:age => (a value > 30)}") | ||
end | ||
|
||
it "fails with a clear message when the matcher does not match" do | ||
expect { | ||
expect(person).to have_attributes(:age => (a_value < 10)) | ||
}.to fail_matching("expected #{object_inspect person} to have attributes {:age => (a value < 10)}") | ||
end | ||
end | ||
end | ||
|
||
describe "expect(...).to_not have_attributes(with_one_attribute)" do | ||
|
||
it "passes if target does not have any of the expected attributes" do | ||
expect(person).to_not have_attributes(:age => wrong_age) | ||
end | ||
|
||
it "fails if target has all of the expected attributes" do | ||
expect { | ||
expect(person).to_not have_attributes(:age => correct_age) | ||
}.to fail_matching(%r|expected #{object_inspect person} not to have attributes #{hash_inspect :age => correct_age}|) | ||
end | ||
|
||
it "fails if target does not responds to any of the attributes" do | ||
expect { | ||
expect(person).to_not have_attributes(:color => 'red') | ||
}.to fail_matching("expected #{object_inspect person} to respond to :color") | ||
end | ||
|
||
it "fails if target responds to the attribute but requires arguments" do | ||
expect { | ||
expect(person).to_not have_attributes(:parent => 'Billy') | ||
}.to fail_matching("expected #{object_inspect person} to respond to :parent with 0 arguments") | ||
end | ||
end | ||
|
||
describe "expect(...).to have_attributes(with_multiple_attributes)" do | ||
|
||
it_behaves_like "an RSpec matcher", :valid_value => Person.new("Correct name", 33), :invalid_value => Person.new("Wrong Name", 11) do | ||
let(:matcher) { have_attributes(:name => "Correct name", :age => 33) } | ||
end | ||
|
||
it "passes if target has the provided attributes" do | ||
expect(person).to have_attributes(:name => correct_name, :age => correct_age) | ||
end | ||
|
||
it "fails if target does not have any of the expected attributes" do | ||
expect { | ||
expect(person).to have_attributes(:name => correct_name, :age => wrong_age) | ||
}.to fail_matching(%r|expected #{object_inspect person} to have attributes #{hash_inspect :name => correct_name, :age => wrong_age}|) | ||
end | ||
|
||
it "fails if target does not responds to any of the attributes" do | ||
expect { | ||
expect(person).to have_attributes(:name => correct_name, :color => 'red') | ||
}.to fail_matching("expected #{object_inspect person} to respond to :color") | ||
end | ||
|
||
it "fails if target responds to the attribute but requires arguments" do | ||
expect { | ||
expect(person).to have_attributes(:name => correct_name, :parent => 'Billy') | ||
}.to fail_matching("expected #{object_inspect person} to respond to :parent with 0 arguments") | ||
end | ||
end | ||
|
||
describe "expect(...).to_not have_attributes(with_multiple_attributes)" do | ||
|
||
it "passes if target has none of the expected attributes" do | ||
expect(person).to_not have_attributes(:name => wrong_name, :age => wrong_age) | ||
end | ||
|
||
it "fails if target has any of the expected attributes" do | ||
expect { | ||
expect(person).to_not have_attributes(:name => wrong_name, :age => correct_age) | ||
}.to fail_matching(%r|expected #{object_inspect person} not to have attributes #{hash_inspect :name => wrong_name, :age => correct_age}|) | ||
end | ||
|
||
it "fails if target has all of the expected attributes" do | ||
expect { | ||
expect(person).to_not have_attributes(:name => correct_name, :age => correct_age) | ||
}.to fail_matching(%r|expected #{object_inspect person} not to have attributes #{hash_inspect :name => correct_name, :age => correct_age}|) | ||
end | ||
|
||
it "fails if target does not responds to any of the attributes" do | ||
expect { | ||
expect(person).to_not have_attributes(:name => correct_name, :color => 'red') | ||
}.to fail_matching("expected #{object_inspect person} to respond to :color") | ||
end | ||
|
||
it "fails if target responds to the attribute but requires arguments" do | ||
expect { | ||
expect(person).to_not have_attributes(:name => correct_name, :parent => 'Billy') | ||
}.to fail_matching("expected #{object_inspect person} to respond to :parent with 0 arguments") | ||
end | ||
end | ||
|
||
|
||
include RSpec::Matchers::Pretty | ||
# We have to use Hash#inspect in examples that have multi-entry | ||
# hashes because the #inspect output on 1.8.7 is non-deterministic | ||
# due to the fact that hashes are not ordered. So we can't simply | ||
# put a literal string for what we expect because it varies. | ||
if RUBY_VERSION.to_f == 1.8 | ||
def hash_inspect(hash) | ||
/\{(#{hash.map { |key,value| "#{key.inspect} => #{value.inspect}.*" }.join "|" }){#{hash.size}}\}/ | ||
end | ||
else | ||
def hash_inspect(hash) | ||
improve_hash_formatting hash.inspect | ||
end | ||
end | ||
|
||
include RSpec::Matchers::Composable | ||
# a helper for failure message assertion | ||
def object_inspect(object) | ||
surface_descriptions_in object.inspect | ||
end | ||
|
||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you should put
# rubocop:enable Style/PredicateName
after the method so it is turned back on. @yujinakayama -- can you confirm?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. If the line contains only the annotation comment, the disablement of cop continues until next
# rubocop:enable Style/PredicateName
appears. On the other hand, if the comment is put after any token (as I suggested), it affects only the line. You can check the range of disablement by runnningrubocop --format progress --format disabled lib
.