Skip to content

Introduce have_reported_error matcher #2849

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 22 commits into
base: main
Choose a base branch
from

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: "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!

return true if @attributes.empty?
return false if @error_subscriber.events.empty?

event_context = @error_subscriber.events.last[1].with_indifferent_access["context"]
Copy link
Member

Choose a reason for hiding this comment

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

Does it lock and match only on the last one reported?

Copy link
Author

@skatkov skatkov Jun 22, 2025

Choose a reason for hiding this comment

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

@pirj yeah, it does. I've been debating this, but this works in our case just fine.

But questions keep popping in my head. Should we look through array of errors? what to do if there will be more than 1? Should we match amount of errors too?

I don't have good answers for these. So this is my dumbest approach -- checking last error only.

Copy link
Member

Choose a reason for hiding this comment

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

We should match like we do with scheduled jobs, if there is a matching one among reported, it matches.

I don’t see a reason to add count matches now. This can be added later.

@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
Copy link
Author

skatkov commented Jun 29, 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.

The only argument that the exception class accepts is "message". We can of course subclass it and support way more than that, but I'm not sure if we should support this in a matcher?

I have a feeling, that having a matcher interface similar to raise_error would make it simpler for people to memorize it. I don't mind changing .with to .have_attributes as well.

@JonRowe
Copy link
Member

JonRowe commented Jun 30, 2025

I hadn't clocked that the extra attributes were in addition to the error, I think matching the raise_error interface in terms of report_error(ErrorClass, message_or_regexp) makes sense with the existing with becoming something clearer, with_metadata? What does Rails call the extra data?

@skatkov
Copy link
Author

skatkov commented Jun 30, 2025

Here is a source code for Rails.error.report.

https://github.com/rails/rails/blob/36601bbb02bc3570f5609db2127c77afca575d6c/activesupport/lib/active_support/error_reporter.rb#L233C23-L233C112

These attributes in rails case have a name "context". so with_context might be more appropriate.

@skatkov skatkov force-pushed the have-reported-error branch 3 times, most recently from c1134da to f90fc3b Compare July 1, 2025 23:20
@skatkov skatkov force-pushed the have-reported-error branch from f90fc3b to 2488a71 Compare July 1, 2025 23:28
# @api private
# Sentinel value to distinguish between no argument passed vs explicitly passed nil.
# This follows the same pattern as RSpec's raise_error matcher.
UndefinedValue = Object.new.freeze
Copy link
Member

Choose a reason for hiding this comment

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

Such a constant is already defined in the basematcher, do we need another one?

end

def failure_message
if !@error_subscriber.events.empty? && !@attributes.empty?
Copy link
Member

Choose a reason for hiding this comment

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

Cosmetic: any? or present??


def failure_message
if !@error_subscriber.events.empty? && !@attributes.empty?
event_context = @error_subscriber.events.last.attributes[:context]
Copy link
Member

Choose a reason for hiding this comment

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

Won’t it be confusing to use last when several errors were reported?

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

Choose a reason for hiding this comment

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

actual_error should be is_a? expected_class, no? Just by looking at the implementation. Or what is the case when they won’t match?


case @expected_message
when Regexp
error.message&.match(@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.

Cosmetic: is safe nav necessary?

it "provides details about mismatched attributes" do
expect {
expect {
Rails.error.report(StandardError.new("test"), context: { user_id: 123, context: "actual" })
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 key is confusing here. Do you mind to change the inner one to something else?

expect {
Rails.error.report(StandardError.new("test"), context: { user_id: 123, context: "actual" })
}.to have_reported_error.with_context(user_id: 456, context: "expected")
}.to fail_with(/Expected error attributes to match {user_id: 456, context: "expected"}, but got these mismatches: {user_id: 456, context: "expected"} and actual values are {"user_id" => 123, "context" => "actual"}/)
Copy link
Member

Choose a reason for hiding this comment

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

Can you please fix specs?

Expected error attributes to match {user_id: 456, context: "expected"}, but got these mismatches: {user_id: 456, context: "expected"} and actual values are {"user_id" => 123, "context" => "actual"}/, got #<RSpec::Expectations::ExpectationNotMetError: Expected error attributes to match {:user_id=>456, :context=>"expected"}, but got these mismatches: {:user_id=>456, :context=>"expected"} and actual values are {"user_id"=>123, "context"=>"actual"}


private

def error_matches_expectation?
Copy link
Member

Choose a reason for hiding this comment

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

Is the last condition necessary? We already check if it’s empty? before calling this method.

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.

A fee minor things and simplifications and it looks good to go.

Let’s leave out matching multiple with chains, severity qualifiers, and all extras for later.

UndefinedValue = Object.new.freeze

# @api private
class ErrorSubscriber
Copy link
Member

Choose a reason for hiding this comment

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

end

def with_context(expected_attributes)
@attributes.merge!(expected_attributes)
Copy link
Member

@pirj pirj Jul 12, 2025

Choose a reason for hiding this comment

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

Cosmetic: Do we need to merge? Maybe just assign?


def matches?(block)
if block.nil?
raise ArgumentError, "this matcher doesn't work with value expectations"
Copy link
Member

Choose a reason for hiding this comment

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

Does this check have the same effect as defining a method supports_value_expectations? that returns false? It should be.

@error_subscriber = ErrorSubscriber.new
::Rails.error.subscribe(@error_subscriber)

block.call
Copy link
Member

Choose a reason for hiding this comment

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

This will become something like reports = ErrorCollector.record(&block)

def error_message_matches?(error)
return true if @expected_message.nil?

case @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.

There should be existing examples amongst our matchers of something like value_matches? that would also allow the use of argument matchers like a_string_including("network").

Copy link
Member

Choose a reason for hiding this comment

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

On a second thought, this may make matching more complicated, as if just one argument is passed, and it’s an argument matcher, what should we match it against, the exception itself, or the exception’s message?

This makes it more compelling to build the with_message qualifier. But it then limits the argument to the matcher to only accept the class. But this is what the Rails’ assertion does anyway.

end

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

Choose a reason for hiding this comment

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

Is it worth memoizing?

#
# @param expected_error_or_message [Class, String, Regexp, nil] the expected error class, message string, or message pattern
# @param expected_message [String, Regexp, nil] the expected error message to match
def have_reported_error(expected_error_or_message = UndefinedValue, expected_message = nil)
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 default attributes both here and in the initializer. Worth leaving just here?

end

def self.process_with_context
Rails.error.report(ArgumentError.new("Invalid input"), context: { context: "user_processing", severity: :error })
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 here, too. Rename inner to topic: or :section?

end

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

Choose a reason for hiding this comment

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

The severity: here is inside the context:, is this correct?

expect {
# Safe operation that doesn't report errors
"safe code"
}.not_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 wonder if the negated form should warn if qualifiers were passed to avoid mistakenly letting unmatched reports through:

          expect {
            User.process_custom_error
          }.not_to have_reported_error("Email is TYPO")

Meaning that if the matcher is used in the negated form, no class, message, or attributes qualifiers are acceptable.

See https://github.com/rspec/rspec/blob/95fbf88255de04e4ba8288172292bda494c4f187/rspec-expectations/lib/rspec/matchers/built_in/raise_error.rb#L162

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