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 21 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 <mail@jonrowe.co.uk>
@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?

@actual_error = nil
@attributes = {}
@error_subscriber = nil

case expected_error_or_message
when nil
when nil, UndefinedValue
Copy link
Member

Choose a reason for hiding this comment

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

We usually make a distinction between those two, where nil is typically a sign of a mistake. For some matchers, we warn if nil is passed. Do you think there can be legitimate cases of passing nil as an argument?

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, similar to error_raised you can just expect an error to be raise, but don't care which one.

Copy link
Member

Choose a reason for hiding this comment

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

The thing is raise_error() is to expect any exception, no matter which; but raise_error(nil) is usually an indication of a mistake in the spec itself. See rspec/rspec-expectations#1142
This warning may explain it better.

Similarly, expect { report… }.to have_reported_error() should pass when any kind of error has been reported, while passing a nil as the first argument should emit a warning.

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