diff --git a/lib/rspec-virtus/matcher.rb b/lib/rspec-virtus/matcher.rb index 38af067..370f7b0 100644 --- a/lib/rspec-virtus/matcher.rb +++ b/lib/rspec-virtus/matcher.rb @@ -1,6 +1,13 @@ module RSpec module Virtus class Matcher + MESSAGES = { + undefined_attribute: 'expected :%{attribute} to be defined in %{subject}', + incorrect_type: 'expected :%{attribute} to be %{expected}, got %{actual}' + }.freeze + + attr_accessor :failure_message + def initialize(attribute_name) @attribute_name = attribute_name @options = {} @@ -18,29 +25,22 @@ def of_type(type, options={}) def matches?(subject) @subject = subject - attribute_exists? && type_correct? - end - def failure_message - "expected #{@attribute_name} to be defined" - end + failure = validate + @failure_message = compose_message(failure) if failure - def failure_message_when_negated - "expected #{@attribute_name} not to be defined" + failure.nil? end private - def attribute - @subject.attribute_set[@attribute_name] - end - - def member_type - attribute.member_type.primitive + def validate + return [:undefined_attribute, {}] unless attribute_exists? + return [:incorrect_type, expected_actual_types] unless type_correct? end - def attribute_type - attribute.primitive + def attribute + @subject.attribute_set[@attribute_name] end def attribute_exists? @@ -49,13 +49,54 @@ def attribute_exists? def type_correct? if @options[:member_type] - member_type == @options[:member_type] && attribute_type == @options[:type] + type_match? && member_type_match? elsif @options[:type] - attribute_type == @options[:type] + type_match? else true end end + + def type_match? + attribute_match?(attribute, @options[:type]) + end + + def member_type_match? + attribute_match?(attribute.member_type, @options[:member_type]) + end + + def attribute_match?(actual, expected) + [actual.class, actual.type, actual.primitive].include?(expected) + end + + def compose_message(failure) + key, params = failure + MESSAGES[key] % { attribute: @attribute_name, subject: @subject }.merge(params) + end + + def expected_actual_types + { + expected: pretty_type(@options[:type], @options[:member_type]), + actual: pretty_type( + pretty_attribute(attribute), + @options[:member_type] && pretty_attribute(attribute.member_type) + ) + } + end + + def attribute_type + attribute.primitive + end + + def pretty_type(type, member_type = nil) + return type.to_s unless member_type + "#{type}[#{member_type}]" + end + + def pretty_attribute(attribute) + return attribute.class if attribute.primitive == BasicObject + attribute.primitive + end end end end diff --git a/spec/acceptance/rspec-virtus_spec.rb b/spec/acceptance/rspec-virtus_spec.rb index 711c194..3b4e39c 100644 --- a/spec/acceptance/rspec-virtus_spec.rb +++ b/spec/acceptance/rspec-virtus_spec.rb @@ -12,5 +12,6 @@ class DummyPost describe DummyPost do it { expect(described_class).to have_attribute(:title) } it { expect(described_class).to have_attribute(:body).of_type(String) } + it { expect(described_class).to have_attribute(:body).of_type(Axiom::Types::String) } it { expect(described_class).to have_attribute(:comments).of_type(Array, member_type: String) } end diff --git a/spec/lib/rspec-virtus/matcher_spec.rb b/spec/lib/rspec-virtus/matcher_spec.rb index 16098c7..3d83157 100644 --- a/spec/lib/rspec-virtus/matcher_spec.rb +++ b/spec/lib/rspec-virtus/matcher_spec.rb @@ -5,11 +5,13 @@ let(:instance) { described_class.new(attribute_name) } let(:attribute_name) { :the_attribute } + class DummyAttribute < Virtus::Attribute; end class DummyVirtus include Virtus.model attribute :the_attribute, String attribute :the_array_attribute, Array[String] + attribute :custom_attribute, DummyAttribute end describe '#matches?' do @@ -20,19 +22,40 @@ class DummyVirtus it { is_expected.to eql(true) } end - context 'successful match on attribute name and type' do + context 'successful match on attribute name and primitive type' do + before { instance.of_type(String) } + + it { is_expected.to eql(true) } + end + + context 'successful match on attribute name and attribute type' do + before { instance.of_type(Axiom::Types::String) } + + it { is_expected.to eql(true) } + end + + context 'successful match on attribute name and custom type' do + let(:attribute_name) { :custom_attribute } + before { instance.of_type(DummyAttribute) } + + it { is_expected.to eql(true) } + end + + context 'successful match on attribute name, type and primitive member_type' do + let(:attribute_name) { :the_array_attribute } + before do - instance.of_type(String) + instance.of_type(Array, member_type: String) end it { is_expected.to eql(true) } end - context 'successful match on attribute name, type and member_type' do + context 'successful match on attribute name, type and attribute member_type' do let(:attribute_name) { :the_array_attribute } before do - instance.of_type(Array, member_type: String) + instance.of_type(Array, member_type: Axiom::Types::String) end it { is_expected.to eql(true) } @@ -103,18 +126,50 @@ class DummyVirtus end describe '#failure_message' do - subject { instance.failure_message } + subject { instance.tap { |i| i.matches?(DummyVirtus) }.failure_message } - it 'tells you which attribute failed' do - expect(subject).to include(attribute_name.to_s) + context 'on absent attribute' do + let(:attribute_name) { :something_else } + + it 'returns absence message' do + message = 'expected :something_else to be defined in DummyVirtus' + expect(subject).to eq message + end end - end - describe '#failure_message_when_negated' do - subject { instance.failure_message_when_negated } + context 'on incorrect type' do + before { instance.of_type(Integer) } + + describe 'attribute type' do + context 'with primitive type' do + it 'returns type primitive' do + message = 'expected :the_attribute to be Integer, got String' + expect(subject).to eq message + end + end + + context 'with attribute' do + let(:attribute_name) { :custom_attribute } + + it 'returns type class' do + message = 'expected :custom_attribute to be Integer, got DummyAttribute' + expect(subject).to eq message + end + end + end + end - it 'tells you which attribute failed' do - expect(subject).to include(attribute_name.to_s) + context 'on incorrect member_type' do + let(:attribute_name) { :the_array_attribute } + + before do + instance.of_type(Array, member_type: Integer) + end + + it 'returns wrong type message' do + message = 'expected :the_array_attribute to be Array[Integer], got Array[String]' + expect(subject).to eq message + end end end end