Skip to content

Instantly share code, notes, and snippets.

@jdickey
Created October 29, 2014 09:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jdickey/5592dbf3532bdf2cb0f5 to your computer and use it in GitHub Desktop.
Save jdickey/5592dbf3532bdf2cb0f5 to your computer and use it in GitHub Desktop.
Example of an RSpec custom matcher wrapping a class. Comments welcome.
# Possible "Version 2" of the :be_saved_user_entity_for matcher, which simply
# instantiates (a similarly updated) `SavedEntityVerifier` and calls methods
# on it.
RSpec::Matchers.define :be_saved_user_entity_for do |source|
match do |actual|
@verifier = SavedEntityVerifier.new(source, actual)
@verifier.rspec_match
end
description do
@verifier.description
end
failure_message do
@verifier.failure_message
end
end # RSpec::Matchers.define :be_saved_user_entity_for
RSpec::Matchers.define :be_saved_user_entity_for do |source|
match do |actual|
@reasons = SavedEntityVerifier.new(source, actual) do
required_in_source :password, :password_confirmation
required_in_actual :created_at, :updated_at
required_in_both :name, :email, :profile
verify
end.reasons
@reasons.empty?
end
description do
[%(have the same name, email and profile fields, with only the source),
%(having password and password-confirmation fields and only the target),
%(having created-on and updated-on timestamps)].join ' '
end
failure_message do
%(Expected a source and target(post-save) entity to #{description}, but ) +
@reasons.join('; and ')
end
end # RSpec::Matchers.define :be_saved_user_entity_for

Here's an example from a public project of a (fairly basic) RSpec custom matcher that wraps an instance of a class that "does the real work"; what's in the actual matcher is mostly the "syntactic sugar" needed for it to play nice with the rest of RSpec.

This was in response to a comment thread on Reddit discussing the question, "Multiple assertions in a test, bad practise?" I want to express my appreciation to /u/jrochkind for his response to my initial comment (that led to my responding to him, that led to a far better approximation of Enlightenment).

The matcher, as I said, is pretty basic:

  • A match block instntiates a SavedEntityVerifier class with the source and actual values for the expectation; specifies which fields in an entity are required in the source, the actual, or both, and calls that instance's #reasons method to get the validation error messages (if any);
  • A description block does just what it says on the tin, explaining what the significance of the source vs actual objects is; and
  • A failure_message block lists out all the detected reasons why the verification failed, with the description as preamble.

Bear in mind that not all of our custom matchers wrap classes like this, but we're starting to encourage it as an internal convention. Which turns out to be fortuitous, because now, it's ridiculously easy to support not just RSpec, but any matcher's "syntactic sugar" using methods on the class being wrapped. This is the epiphany that /u/jrochkind pushed me into.

Thoughts?

# Verifies that an entity has required fields and omits prohibited fdields.
class SavedEntityVerifier
attr_reader :reasons
def initialize(source, actual, &block)
@source, @actual = source, actual
@required_in_both, @required_in_source, @required_in_actual = [], [], []
@reasons = []
instance_eval(&block) if block
end
def verify
verify_required_common_fields
verify_required_exclusive_fields true
verify_required_exclusive_fields false
@reasons.flatten!
@reasons.empty?
end
private
attr_reader :source, :actual, :required_in_source, :required_in_actual
attr_writer :reasons
def check_prohibited_field(obj, field, in_source)
return unless obj.send(field)
which_str = which_str_for true, in_source
@reasons << field_exist_reason(field, false, which_str)
end
def check_required_field(obj, field, in_source)
return if obj.send(field)
which_str = which_str_for true, in_source
@reasons << field_exist_reason(field, true, which_str)
end
def field_exist_reason(field, must, which)
field_str = field.to_s.split('_').join ' '
must_str = must ? 'must' : 'must not'
[which.capitalize, must_str, 'have a', field_str, 'field'].join(' ')
end
def required_in_both(*attrs)
@required_in_both = Array(attrs)
end
def required_in_source(*attrs)
@required_in_source = Array(attrs)
end
def required_in_actual(*attrs)
@required_in_actual = Array(attrs)
end
def add_reason_if(field_sym)
actual_field = actual.send field_sym
source_field = source.send field_sym
return if actual_field == source_field
message = %(#{field_sym} fields do not match: got #{actual_field} but ) +
%(expected #{source_field})
@reasons << message
end
def verify_required_common_fields
@required_in_both.each { |field| add_reason_if field }
end
def direction_for_musts(in_source)
must, must_not = if in_source
[source, actual]
else
[actual, source]
end
[must, must_not]
end
def verify_required_exclusive_fields(in_source)
fields = in_source ? @required_in_source : @required_in_actual
return if fields.nil? || fields.empty?
must_have, must_not_have = direction_for_musts(in_source)
fields.each do |field|
check_required_field must_have, field, in_source
check_prohibited_field must_not_have, field, in_source
end
end
def which_str_for(flag, in_source)
flag == in_source ? 'source' : 'actual'
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment