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

TODO

Outline of things that we want to do before marking this as completed.

@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!

@skatkov skatkov force-pushed the have-reported-error branch 3 times, most recently 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
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 4 times, most recently from f90fc3b to 2488a71 Compare July 1, 2025 23:28
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?

Copy link
Author

Choose a reason for hiding this comment

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

yeah, sometimes error object could be nil.

Copy link
Member

Choose a reason for hiding this comment

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

How? Please add a spec for this case.


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
Author

Choose a reason for hiding this comment

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

This particular method checks that the error matches one that we expect, not just that the error occurred.

Copy link
Member

Choose a reason for hiding this comment

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

Here we check if @reports.empty? and return.

Right after the check, we call error_matches_expectation?.

The last condition check @reports.count.positive? here is redundant.

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.


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.

Copy link
Author

Choose a reason for hiding this comment

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

RSpec is actually allowing the value expectation to reach the matches? method, even with supports_value_expectations? defined as false. This mechanism is NOT preventing the value expectation from reaching matches?. The explicit check was necessary in my testing! But maybe I missed something?

Copy link
Member

Choose a reason for hiding this comment

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

Ha! Nice catch. The RSpec.deprecate call somehow didn't fail the build. I'll have a closer look.

The block is not nil though for expect(:foo).to reports_error, so this check is useless in its current implementation.
I suggest removing it, and relying on the supports_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.

I recall now that we kept in 3.x the possibility to pass a lambda to expect, or define subject as a lambda and use is_expected.to with block matchers. We just print a deprecation message now.

Swallowed deprecation messages are due to #2857

Copy link
Member

Choose a reason for hiding this comment

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

After the aftermentioned fix, with

        def supports_value_expectations?
          false
        end

The following passes:

  it "warns when used as a value expectation" do
    expect {
      expect(Rails.error.report(StandardError.new("test error"))).to have_reported_error
    }.to raise_error(/implicit block expectation.+deprecated/)
  end

We can change the code when that swallowed deprecation fix is merged.

Copy link
Member

Choose a reason for hiding this comment

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

This can be removed now and any test marked as pending with the reason instead.

Copy link
Author

Choose a reason for hiding this comment

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

Done.

#
# @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?

Copy link
Author

Choose a reason for hiding this comment

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

Resolved!

Copy link
Member

Choose a reason for hiding this comment

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

There are still defaults both here and there:

def initialize(expected_error_or_message = UndefinedValue, expected_message = nil)

Let's remove there.

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?

skatkov and others added 28 commits September 24, 2025 13:13
Commit grammar improvements

Co-authored-by: Jon Rowe <[email protected]>
@skatkov skatkov force-pushed the have-reported-error branch from b5568a0 to aa4c84d Compare September 24, 2025 11:13
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