Permalink
Browse files

Merge pull request #571 from yelled3/add_have_attributes_matcher

New matcher: `have_attributes`
  • Loading branch information...
2 parents ac3810d + 70794d2 commit 6f975b08c996b1014654334229d5d4b020055690 @myronmarston myronmarston committed Jun 24, 2014
View
@@ -35,6 +35,15 @@ Bug Fixes:
expecatations) weren't being used. (Myron Marston, #566)
* Structs are no longer treated as arrays when diffed. (Jon Rowe, #576)
+Enhancements:
+
+* Add `have_attributes` matcher, that passes if actual's attribute
+ values match the expected attributes hash:
+ `Person = Struct.new(:name, :age)`
+ `person = Person.new("Bob", 32)`
+ `expect(person).to have_attributes(:name => "Bob", :age => 32)`.
+ (Adam Farhi)
+
### 3.0.0 / 2014-06-01
[Full Changelog](http://github.com/rspec/rspec-expectations/compare/v3.0.0.rc1...v3.0.0)
@@ -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"
View
@@ -551,6 +551,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)
+ BuiltIn::HaveAttributes.new(expected)
+ end
+ # 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.
@@ -30,6 +30,7 @@ module BuiltIn
autoload :Equal, 'rspec/matchers/built_in/equal'
autoload :Exist, 'rspec/matchers/built_in/exist'
autoload :Has, 'rspec/matchers/built_in/has'
+ autoload :HaveAttributes, 'rspec/matchers/built_in/have_attributes'
autoload :Include, 'rspec/matchers/built_in/include'
autoload :All, 'rspec/matchers/built_in/all'
autoload :Match, 'rspec/matchers/built_in/match'
@@ -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
+
+ 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
@@ -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
+
specify do
expect(
a_string_including("a")
@@ -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
+ 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

0 comments on commit 6f975b0

Please sign in to comment.