Skip to content

Instantly share code, notes, and snippets.

@GuyPaddock
Created February 13, 2018 04:07
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save GuyPaddock/707126abc6589c14db756f1a4f4835ae to your computer and use it in GitHub Desktop.
Save GuyPaddock/707126abc6589c14db756f1a4f4835ae to your computer and use it in GitHub Desktop.
An RSpec matcher for doing precise checks on active model validations errors.
##
# An RSpec matcher for checking Rails validation errors on an object.
#
# Usage:
# # Expect exactly three validation errors:
# # - field1 must not be empty
# # - field1 must be a number
# # - field2 must be greater than zero
# expect(x).to have_validation_errors
# .related_to(:field1)
# .that_say("must not be empty", "must be a number")
# .and_others
# .related_to(:field2)
# .that_say("must be greater than zero")
#
# # Expect no validation errors
# expect(x).not_to have_validation_errors
#
# # Expect no validation errors on field1 that say
# # "field1 must not be empty" or "field1 must be a number"
# expect(x).not_to have_validation_errors
# .related_to(:field1)
# .that_say("must not be empty", "must be a number")
#
RSpec::Matchers.define :have_validation_errors do
match do |actual|
total_error_count = self.total_error_count
if actual.respond_to?(:errors)
if total_error_count > 0
(actual.errors.size == total_error_count) && all_errors_match?(actual)
else
actual.errors.size > 0
end
end
end
match_when_negated do |actual|
total_error_count = self.total_error_count
!actual.respond_to?(:errors) ||
((total_error_count > 0) && all_errors_excluded?(actual)) ||
(actual.errors.size == 0)
end
failure_message do |actual|
super_message = super()
if actual.respond_to?(:errors) && actual.errors
"#{super_message}, but got #{actual.errors.full_messages}"
else
"#{super_message}, but got no validation errors."
end
end
failure_message_when_negated do |actual|
super_message = super()
"#{super_message}, but got #{actual.errors.full_messages}"
end
chain :related_to do |field|
@field_errors ||= {}
@field_errors[field] ||= []
@last_field = field
end
chain :that_say do |*messages|
unless @last_field
raise 'last_field must be preceded by related_to.'
end
@field_errors[@last_field] += messages
end
chain :and_others do
# This is a placeholder for fluency
end
##
# Calculates the total number of expected validation errors across all of the
# fields.
#
# @return [Integer]
#
def total_error_count
if @field_errors
@field_errors.inject(0) { |memo, (field, errors)| memo + errors.size }
else
0
end
end
##
# Matches the errors on the provided actual object against this matcher's
# expectations.
#
# @param [ActiveModel::Validations] actual
# The object whose validation errors will be matched against this object.
#
def all_errors_match?(actual)
@field_errors.all? do |(field_name, expected_errors)|
actual_errors = actual.errors[field_name]
if actual_errors
expected_errors.all? do |expected_error|
actual_errors.include? expected_error
end
end
end
end
##
# Ensures the errors on the provided actual object exclude all of the ones
# in this matcher's expectations.
#
# @param [ActiveModel::Validations] actual
# The object whose validation errors will be matched against this object.
#
def all_errors_excluded?(actual)
@field_errors.all? do |(field_name, excluded_errors)|
actual_errors = actual.errors[field_name]
actual_errors.blank? || excluded_errors.none? do |excluded_error|
actual_errors.include? excluded_error
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment