-
-
Notifications
You must be signed in to change notification settings - Fork 1k
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
base: main
Are you sure you want to change the base?
Conversation
8b837a8
to
d2d0e51
Compare
There was a problem hiding this 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"] |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
b38ff1c
to
87ebd2a
Compare
71685ad
to
6135d5d
Compare
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 | ||
|
There was a problem hiding this comment.
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.
👋 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. |
Commit grammar improvements Co-authored-by: Jon Rowe <[email protected]>
@JonRowe thanks for review.
Which of these you would rather go with? or
?? I assume, that first option is prefered to keep similarity with |
Given that you already have
But if you insisted on |
It makes sense to separate matching of args passed to |
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 |
I hadn't clocked that the extra attributes were in addition to the error, I think matching the |
Here is a source code for Rails.error.report. These attributes in rails case have a name "context". so |
c1134da
to
f90fc3b
Compare
f90fc3b
to
2488a71
Compare
# @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 |
There was a problem hiding this comment.
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? |
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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" }) |
There was a problem hiding this comment.
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"}/) |
There was a problem hiding this comment.
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? |
There was a problem hiding this comment.
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.
There was a problem hiding this 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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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")
.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 }) |
There was a problem hiding this comment.
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 }) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
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