Skip to content

Commit c8f1a16

Browse files
committed
first pass at have_reported_error matcher
1 parent 45fa1fb commit c8f1a16

File tree

3 files changed

+427
-0
lines changed

3 files changed

+427
-0
lines changed

lib/rspec/rails/matchers.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ module Matchers
2020
require 'rspec/rails/matchers/relation_match_array'
2121
require 'rspec/rails/matchers/be_valid'
2222
require 'rspec/rails/matchers/have_http_status'
23+
require 'rspec/rails/matchers/have_reported_error'
2324
require 'rspec/rails/matchers/send_email'
2425

2526
if RSpec::Rails::FeatureCheck.has_active_job?
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
require "rspec/rails/matchers/base_matcher"
2+
3+
module RSpec
4+
module Rails
5+
module Matchers
6+
# @private
7+
class ErrorSubscriber
8+
attr_reader :events
9+
10+
def initialize
11+
@events = []
12+
end
13+
14+
def report(error, **attrs)
15+
@events << [error, attrs]
16+
end
17+
end
18+
19+
# Matcher class for `have_reported_error`. Should not be instantiated directly.
20+
#
21+
# @private
22+
# @see RSpec::Rails::Matchers#have_reported_error
23+
class HaveReportedError < RSpec::Rails::Matchers::BaseMatcher
24+
def initialize(expected_error = nil)
25+
@expected_error = expected_error
26+
@attributes = {}
27+
@error_subscriber = nil
28+
end
29+
30+
def with(expected_attributes)
31+
@attributes.merge!(expected_attributes)
32+
self
33+
end
34+
35+
def matches?(block)
36+
@error_subscriber = ErrorSubscriber.new
37+
::Rails.error.subscribe(@error_subscriber)
38+
39+
block&.call
40+
41+
return false if @error_subscriber.events.empty? && !@expected_error.nil?
42+
return false unless error_matches_expectation?
43+
44+
return attributes_match_if_specified?
45+
ensure
46+
::Rails.error.unsubscribe(@error_subscriber) if @error_subscriber
47+
end
48+
49+
def supports_block_expectations?
50+
true
51+
end
52+
53+
def description
54+
desc = "report an error"
55+
case @expected_error
56+
when Class
57+
desc = "report a #{@expected_error} error"
58+
when Exception
59+
desc = "report a #{@expected_error.class} error"
60+
desc += " with message '#{@expected_error.message}'" unless @expected_error.message.empty?
61+
when Regexp
62+
desc = "report an error with message matching #{@expected_error}"
63+
when Symbol
64+
desc = "report #{@expected_error}"
65+
end
66+
desc += " with #{@attributes}" unless @attributes.empty?
67+
desc
68+
end
69+
70+
def failure_message
71+
if !@error_subscriber.events.empty? && !@attributes.empty?
72+
event_data = @error_subscriber.events.last[1]
73+
if defined?(ActiveSupport::HashWithIndifferentAccess)
74+
event_data = event_data.with_indifferent_access
75+
end
76+
unmatched = unmatched_attributes(event_data)
77+
unless unmatched.empty?
78+
return "Expected error attributes to match #{@attributes}, but got these mismatches: #{unmatched} and actual values are #{event_data}"
79+
end
80+
elsif @error_subscriber.events.empty?
81+
return 'Expected the block to report an error, but none was reported.'
82+
else
83+
case @expected_error
84+
when Class
85+
return "Expected error to be an instance of #{@expected_error}, but got #{actual_error.class} with message: '#{actual_error.message}'"
86+
when Exception
87+
return "Expected error to be #{@expected_error.class} with message '#{@expected_error.message}', but got #{actual_error.class} with message: '#{actual_error.message}'"
88+
when Regexp
89+
return "Expected error message to match #{@expected_error}, but got: '#{actual_error.message}'"
90+
when Symbol
91+
return "Expected error to be #{@expected_error}, but got: #{actual_error}"
92+
else
93+
return "Expected specific error, but got #{actual_error.class} with message: '#{actual_error.message}'"
94+
end
95+
end
96+
end
97+
98+
def failure_message_when_negated
99+
error_count = @error_subscriber.events.count
100+
if defined?(ActiveSupport::Inflector)
101+
error_word = 'error'.pluralize(error_count)
102+
verb = error_count == 1 ? 'has' : 'have'
103+
else
104+
error_word = error_count == 1 ? 'error' : 'errors'
105+
verb = error_count == 1 ? 'has' : 'have'
106+
end
107+
"Expected the block not to report any errors, but #{error_count} #{error_word} #{verb} been reported."
108+
end
109+
110+
private
111+
112+
def error_matches_expectation?
113+
return true if @expected_error.nil? && @error_subscriber.events.any?
114+
return false if actual_error.nil?
115+
116+
case @expected_error
117+
when Class
118+
actual_error.is_a?(@expected_error)
119+
when Exception
120+
actual_error.is_a?(@expected_error.class) &&
121+
(@expected_error.message.empty? || actual_error.message == @expected_error.message)
122+
when Regexp
123+
actual_error.message&.match(@expected_error)
124+
when Symbol
125+
actual_error == @expected_error
126+
else
127+
true
128+
end
129+
end
130+
131+
def attributes_match_if_specified?
132+
return true if @attributes.empty?
133+
return false if @error_subscriber.events.empty?
134+
135+
event_data = @error_subscriber.events.last[1]
136+
attributes_match?(event_data)
137+
end
138+
139+
def actual_error
140+
@error_subscriber.events.empty? ? nil : @error_subscriber.events.last[0]
141+
end
142+
143+
def attributes_match?(actual)
144+
@attributes.all? do |key, value|
145+
if defined?(RSpec::Matchers) && value.respond_to?(:matches?)
146+
value.matches?(actual[key])
147+
else
148+
actual[key] == value
149+
end
150+
end
151+
end
152+
153+
def unmatched_attributes(actual)
154+
@attributes.reject do |key, value|
155+
if defined?(RSpec::Matchers) && value.respond_to?(:matches?)
156+
value.matches?(actual[key])
157+
else
158+
actual[key] == value
159+
end
160+
end
161+
end
162+
end
163+
164+
# @api public
165+
# Passes if the block reports an error to `Rails.error`.
166+
#
167+
# This matcher asserts that ActiveSupport::ErrorReporter has received an error report.
168+
#
169+
# @example Checking for any error
170+
# expect { Rails.error.report(StandardError.new) }.to have_reported_error
171+
#
172+
# @example Checking for specific error class
173+
# expect { Rails.error.report(MyError.new) }.to have_reported_error(MyError)
174+
#
175+
# @example Checking for specific error instance with message
176+
# expect { Rails.error.report(MyError.new("message")) }.to have_reported_error(MyError.new("message"))
177+
#
178+
# @example Checking error attributes
179+
# expect { Rails.error.report(StandardError.new, context: "test") }.to have_reported_error.with(context: "test")
180+
#
181+
# @example Checking error message patterns
182+
# expect { Rails.error.report(StandardError.new("test message")) }.to have_reported_error(/test/)
183+
#
184+
# @example Negation
185+
# expect { "safe code" }.not_to have_reported_error
186+
#
187+
# @param expected_error [Class, Exception, Regexp, Symbol, nil] the expected error to match
188+
def have_reported_error(expected_error = nil)
189+
HaveReportedError.new(expected_error)
190+
end
191+
end
192+
end
193+
end

0 commit comments

Comments
 (0)