Skip to content

Commit 78caf47

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

File tree

3 files changed

+418
-0
lines changed

3 files changed

+418
-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: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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+
case @expected_error
42+
when Class
43+
return false unless actual_error&.is_a?(@expected_error)
44+
when Exception
45+
return false unless actual_error&.is_a?(@expected_error.class)
46+
unless @expected_error.message.empty?
47+
return false unless actual_error.message == @expected_error.message
48+
end
49+
when nil
50+
return false unless @error_subscriber.events.count >= 1
51+
when Regexp
52+
return false unless actual_error&.message&.match(@expected_error)
53+
when Symbol
54+
return false unless actual_error == @expected_error
55+
end
56+
57+
if !@attributes.empty? && !@error_subscriber.events.empty?
58+
event_data = @error_subscriber.events.last[1]
59+
return attributes_match?(event_data)
60+
end
61+
62+
true
63+
ensure
64+
::Rails.error.unsubscribe(@error_subscriber) if @error_subscriber
65+
end
66+
67+
def supports_block_expectations?
68+
true
69+
end
70+
71+
def description
72+
desc = "report an error"
73+
case @expected_error
74+
when Class
75+
desc = "report a #{@expected_error} error"
76+
when Exception
77+
desc = "report a #{@expected_error.class} error"
78+
desc += " with message '#{@expected_error.message}'" unless @expected_error.message.empty?
79+
when Regexp
80+
desc = "report an error with message matching #{@expected_error}"
81+
when Symbol
82+
desc = "report #{@expected_error}"
83+
end
84+
desc += " with #{@attributes}" unless @attributes.empty?
85+
desc
86+
end
87+
88+
def failure_message
89+
if !@error_subscriber.events.empty? && !@attributes.empty?
90+
event_data = @error_subscriber.events.last[1]
91+
if defined?(ActiveSupport::HashWithIndifferentAccess)
92+
event_data = event_data.with_indifferent_access
93+
end
94+
unmatched = unmatched_attributes(event_data)
95+
unless unmatched.empty?
96+
return "Expected error attributes to match #{@attributes}, but got these mismatches: #{unmatched} and actual values are #{event_data}"
97+
end
98+
elsif @error_subscriber.events.empty?
99+
return 'Expected the block to report an error, but none was reported.'
100+
else
101+
case @expected_error
102+
when Class
103+
return "Expected error to be an instance of #{@expected_error}, but got #{actual_error.class} with message: '#{actual_error.message}'"
104+
when Exception
105+
return "Expected error to be #{@expected_error.class} with message '#{@expected_error.message}', but got #{actual_error.class} with message: '#{actual_error.message}'"
106+
when Regexp
107+
return "Expected error message to match #{@expected_error}, but got: '#{actual_error.message}'"
108+
when Symbol
109+
return "Expected error to be #{@expected_error}, but got: #{actual_error}"
110+
else
111+
return "Expected specific error, but got #{actual_error.class} with message: '#{actual_error.message}'"
112+
end
113+
end
114+
end
115+
116+
def failure_message_when_negated
117+
error_count = @error_subscriber.events.count
118+
if defined?(ActiveSupport::Inflector)
119+
error_word = 'error'.pluralize(error_count)
120+
verb = error_count == 1 ? 'has' : 'have'
121+
else
122+
error_word = error_count == 1 ? 'error' : 'errors'
123+
verb = error_count == 1 ? 'has' : 'have'
124+
end
125+
"Expected the block not to report any errors, but #{error_count} #{error_word} #{verb} been reported."
126+
end
127+
128+
private
129+
130+
def actual_error
131+
@error_subscriber.events.empty? ? nil : @error_subscriber.events.last[0]
132+
end
133+
134+
def attributes_match?(actual)
135+
@attributes.all? do |key, value|
136+
if defined?(RSpec::Matchers) && value.respond_to?(:matches?)
137+
value.matches?(actual[key])
138+
else
139+
actual[key] == value
140+
end
141+
end
142+
end
143+
144+
def unmatched_attributes(actual)
145+
@attributes.reject do |key, value|
146+
if defined?(RSpec::Matchers) && value.respond_to?(:matches?)
147+
value.matches?(actual[key])
148+
else
149+
actual[key] == value
150+
end
151+
end
152+
end
153+
end
154+
155+
# @api public
156+
# Passes if the block reports an error to `Rails.error`.
157+
#
158+
# This matcher asserts that ActiveSupport::ErrorReporter has received an error report.
159+
#
160+
# @example Checking for any error
161+
# expect { Rails.error.report(StandardError.new) }.to have_reported_error
162+
#
163+
# @example Checking for specific error class
164+
# expect { Rails.error.report(MyError.new) }.to have_reported_error(MyError)
165+
#
166+
# @example Checking for specific error instance with message
167+
# expect { Rails.error.report(MyError.new("message")) }.to have_reported_error(MyError.new("message"))
168+
#
169+
# @example Checking error attributes
170+
# expect { Rails.error.report(StandardError.new, context: "test") }.to have_reported_error.with(context: "test")
171+
#
172+
# @example Checking error message patterns
173+
# expect { Rails.error.report(StandardError.new("test message")) }.to have_reported_error(/test/)
174+
#
175+
# @example Negation
176+
# expect { "safe code" }.not_to have_reported_error
177+
#
178+
# @param expected_error [Class, Exception, Regexp, Symbol, nil] the expected error to match
179+
def have_reported_error(expected_error = nil)
180+
HaveReportedError.new(expected_error)
181+
end
182+
end
183+
end
184+
end

0 commit comments

Comments
 (0)