Skip to content

Conversation

skatkov
Copy link

@skatkov skatkov commented Jun 4, 2025

fixes #2827

rspec-rails is missing support for Rails ErrorReporter, this was introduced to rails in v7 and has been evolving ever since. With my client, we have moved to using ErrorReporter as a unified error reporting interface, so we can easily move from one error tracking software to another with minimal code changes. And we had a need to test this interface with rspec, so we implemented our own matcher to handle this.

I'm suggesting our internal implementation as is. This is probably not suitable as is for this gem, but I'd like to open discussion with this starting point.

Example usage

@example Checking for any error
  expect { Rails.error.report(StandardError.new) }.to have_reported_error

@example Checking for specific error class
  expect { Rails.error.report(MyError.new) }.to have_reported_error(MyError)

@example Checking for specific error instance with message
  expect { Rails.error.report(MyError.new("message")) }.to have_reported_error(MyError.new("message"))

@example Checking error attributes
  expect { Rails.error.report(StandardError.new, context: "test") }.to have_reported_error.with_context(section: "test")

@example Checking error message patterns
  expect { Rails.error.report(StandardError.new("test message")) }.to have_reported_error(/test/)

@example Negation
  expect { "safe code" }.not_to have_reported_error

@skatkov skatkov force-pushed the have-reported-error branch from 8b837a8 to d2d0e51 Compare June 7, 2025 20:44
Copy link
Member

@pirj pirj left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!


```ruby
# passes if Rails.error.report was called with any error
expect { Rails.error.report(StandardError.new) }.to have_reported_error
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this file is supposed to contain just the outline of the matchers defined here (and at a glance it’s rather incomplete). What do you think of keeping just one example of usage of this matcher? The rest will be taken into the same documentation from the feature file.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very verbose for us... These lines take up half this file when our primary documentation is the feature file itself...

@skatkov skatkov force-pushed the have-reported-error branch 2 times, most recently from b38ff1c to 87ebd2a Compare June 22, 2025 20:50
@skatkov skatkov force-pushed the have-reported-error branch from 71685ad to 6135d5d Compare June 22, 2025 20:59
Comment on lines 183 to 218
Scenario: Using in controller specs
Given a file named "spec/controllers/users_controller_spec.rb" with:
"""ruby
require "rails_helper"

RSpec.describe UsersController, type: :controller do
describe "POST #create" do
it "reports validation errors" do
expect {
post :create, params: { user: { email: "invalid" } }
}.to have_reported_error(ValidationError)
end
end
end
"""
When I run `rspec spec/controllers/users_controller_spec.rb`
Then the examples should all pass

Scenario: Using in request specs
Given a file named "spec/requests/users_spec.rb" with:
"""ruby
require "rails_helper"

RSpec.describe "Users", type: :request do
describe "POST /users" do
it "reports processing errors" do
expect {
post "/users", params: { user: { name: "Test" } }
}.to have_reported_error.with(context: "user_creation")
end
end
end
"""
When I run `rspec spec/requests/users_spec.rb`
Then the examples should all pass

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a controller / request specific matcher so these feel out of place, why these, why not every aspect of Rails... I'd just cut them.

@JonRowe
Copy link
Member

JonRowe commented Jun 25, 2025

👋 I think this would be a great addition, sorry for the size of the review its been on my todo list for a while, its mostly just grammar / wording tweaks plus a few "fit" things.

The one change I do want to see though is dropping instance matching, I don't think it makes sense over just providing class / message / using with.

skatkov and others added 2 commits June 25, 2025 23:21
Commit grammar improvements

Co-authored-by: Jon Rowe <[email protected]>
@skatkov
Copy link
Author

skatkov commented Jun 27, 2025

@JonRowe thanks for review.

The one change I do want to see though is dropping instance matching, I don't think it makes sense over just providing class / message / using with.

Which of these you would rather go with?
.have_reported_error(StandardError, /wrong attributes/).with(param1: 'test')

or

.have_reported_error(StandardError).with(message:/wrong attributes/, params1: 'test1')

??

I assume, that first option is prefered to keep similarity with raised_error matcher.
https://rspec.info/features/3-13/rspec-expectations/built-in-matchers/raise-error/

@JonRowe
Copy link
Member

JonRowe commented Jun 27, 2025

Given that you already have with, I think:

.have_reported_error(StandardError).with(/wrong attributes/, params1: 'test1')

But if you insisted on message: in with I could be persuaded

@pirj
Copy link
Member

pirj commented Jun 27, 2025

It makes sense to separate matching of args passed to report and exception’s own args, including the message. Both can receive args, right? It would open a way for ambiguity and confusion.
raise_error accepts have_attributes as an argument matcher. It supports with_message. Wdyt if we reserve with for additional args passed to report, but keep it separate from with_message?

@skatkov skatkov force-pushed the have-reported-error branch 3 times, most recently from 605ab75 to bd4e048 Compare August 31, 2025 11:37
Comment on lines +25 to +26
@expected_error = nil
@expected_message = expected_message
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the first argument was not passed at all and defaulted to UNDEFINED, the second, expected_message will also be nil?

Suggested change
@expected_error = nil
@expected_message = expected_message
@expected_error = @expected_message = nil

Comment on lines 132 to 133
Rails.error.report(StandardError.new("test"), context: { user_id: 123, context: "test" })
}.to have_reported_error.with_context(user_id: 123, context: "test")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested context strikes back.

it "passes with partial attribute matching" do
expect {
Rails.error.report(
StandardError.new("test"), context: { user_id: 123, context: "test", extra: "data" }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, too

expect {
Rails.error.report(StandardError.new("actual message"))
}.to have_reported_error("expected message")
}.to fail_with(/Expected error message to be 'expected message', but got: StandardError/)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that all the failure message? Does it include the actual exception message? Or is it just skipped for brevity in this expectation?

end

def self.process_with_context
Rails.error.report(ArgumentError.new("Invalid input"), severity: :error , context: { topic: "user_processing"})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Rails.error.report(ArgumentError.new("Invalid input"), severity: :error , context: { topic: "user_processing"})
Rails.error.report(ArgumentError.new("Invalid input"), severity: :error, context: { topic: "user_processing" })

private

def error_matches_expectation?
return true if @expected_error.nil? && @expected_message.nil? && @reports.count.positive?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return true if @expected_error.nil? && @expected_message.nil? && @reports.count.positive?
return true if @expected_error.nil? && @expected_message.nil?

end

def failure_message_when_negated
warn_about_negated_with_qualifiers! if has_qualifiers?
Copy link
Member

@pirj pirj Aug 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ This is a bad place to warn.
There might be no failure due to a false positive.
Please move to does_not_match? like here.

@reports.find do |report|
error_class_matches?(report.error) && error_message_matches?(report.error)
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if there were two errors reported, same class, same message, but the attributes (context) only match on the second one, not the first one that find finds?

There is some repetition between this method and those lines.

Comment on lines +187 to +192
@attributes.all? do |key, value|
actual_value = actual[key] || actual[key.to_s] || actual[key.to_sym]
if value.respond_to?(:matches?)
value.matches?(actual_value)
else
actual_value == value
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have this, should fit nicely here:

Suggested change
@attributes.all? do |key, value|
actual_value = actual[key] || actual[key.to_s] || actual[key.to_sym]
if value.respond_to?(:matches?)
value.matches?(actual_value)
else
actual_value == value
end
end
values_match?(@attributes, actual)

There is a difference, though - we don't coerce keys to strings. I'm more inclined to be strict here and don't coerce unless you have a good argument for being indifferent to keys' type.

Comment on lines +197 to +204
def unmatched_attributes(actual)
@attributes.reject do |key, value|
actual_value = actual[key] || actual[key.to_s] || actual[key.to_sym]
if value.respond_to?(:matches?)
value.matches?(actual_value)
else
actual_value == value
end
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just report the entire actual context for simplicity, not the diff?

@pirj
Copy link
Member

pirj commented Aug 31, 2025

I apologise for some code review notes that felt off - I had an outdated commit open in one of the tabs 🙏

end

def failure_message
if [email protected]? && [email protected]?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if !@reports.empty? && !@attributes.empty?
if @reports.any? && @attributes.any?


def failure_message
if [email protected]? && [email protected]?
report_context = @reports.last.context
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

last is incorrect if several errors could have been reported.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there were no errors reported matching by class/message, even if the expectation has a with_context qualifier, don't even mention context attributes in the failure message.

But if some reports did match by class/message (or ALL if the expectation did not specify class/message/regexp), list those errors with their attributes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matching_reports (plural) should do better

end

def actual_error
@actual_error ||= matching_report&.error
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@actual_error ||= matching_report&.error
matching_report&.error

Memoization feels redundant

end
else
if @expected_error && !actual_error.is_a?(@expected_error)
return "Expected error to be an instance of #{@expected_error}, but got #{actual_error.class} with message: '#{actual_error.message}'"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this work?
actual_error is matching_report&.error where matching_report is:

          @reports.find do |report|
            error_class_matches?(report.error) && error_message_matches?(report.error)

and error_class_matches? is:

@expected_error.nil? || error.is_a?(@expected_error)

Above, we check that @expected_error is not nil.

How on Earth can actual_error not be of @expected_error class?

Reduced:

actual_error.is_a?(@expected_error) && !actual_error.is_a?(@expected_error)

How does that spec pass?

@skatkov skatkov force-pushed the have-reported-error branch from ee09016 to b5568a0 Compare September 1, 2025 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support for Rails 7.1 error reporter
3 participants