diff --git a/features/matchers/README.md b/features/matchers/README.md index 1fc7b5388..9b67683ee 100644 --- a/features/matchers/README.md +++ b/features/matchers/README.md @@ -24,3 +24,30 @@ expect(response).to render_template(template_name) # and it is not persisted expect(assigns(:widget)).to be_a_new(Widget) ``` + +### error reporting + +```ruby +# passes when any error is reported +expect { Rails.error.report(StandardError.new) }.to have_reported_error + +# passes when specific error class is reported +expect { Rails.error.report(MyError.new) }.to have_reported_error(MyError) + +# passes when specific error class with exact message is reported +expect { Rails.error.report(MyError.new("message")) }.to have_reported_error(MyError, "message") + +# passes when specific error class with message matching pattern is reported +expect { Rails.error.report(MyError.new("test message")) }.to have_reported_error(MyError, /test/) + +# passes when any error with exact message is reported +expect { Rails.error.report(StandardError.new("exact message")) }.to have_reported_error("exact message") + +# passes when any error with message matching pattern is reported +expect { Rails.error.report(StandardError.new("test message")) }.to have_reported_error(/test/) + +# passes when error is reported with specific context attributes +expect { Rails.error.report(StandardError.new, context: { user_id: 123 }) }.to have_reported_error.with_context(user_id: 123) + + +``` diff --git a/features/matchers/have_reported_error_matcher.feature b/features/matchers/have_reported_error_matcher.feature new file mode 100644 index 000000000..3c3a4379d --- /dev/null +++ b/features/matchers/have_reported_error_matcher.feature @@ -0,0 +1,199 @@ +Feature: `have_reported_error` matcher + + The `have_reported_error` matcher is used to check if an error was reported + to Rails error reporting system (`Rails.error`). It can match against error + classes, messages, and attributes. + + The matcher supports several matching strategies: + * Any error reported + * A specific error class + * A specific error class with message + * Error message patterns using regular expressions + * Message-only matching (any class) + * Error attributes using `.with_context()` + + The matcher is available in all spec types where Rails error reporting is used. + + Background: + Given a file named "app/models/user.rb" with: + """ruby + class User < ApplicationRecord + class ValidationError < StandardError; end + def self.process_data + Rails.error.report(StandardError.new("Processing failed")) + end + + def self.process_with_context + Rails.error.report(ArgumentError.new("Invalid input"), context: { context: "user_processing", severity: :error }) + end + + def self.process_custom_error + Rails.error.report(ValidationError.new("Email is invalid")) + end + end + """ + + Scenario: Checking for any error being reported + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe User do + it "reports errors" do + expect { + User.process_data + }.to have_reported_error + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking for message-only matching + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe User do + it "reports error with exact message (any class)" do + expect { + User.process_data + }.to have_reported_error("Processing failed") + end + + it "reports error with message pattern (any class)" do + expect { + User.process_custom_error + }.to have_reported_error(/Email/) + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking for a specific error class + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe User do + it "reports a StandardError" do + expect { + User.process_data + }.to have_reported_error(StandardError) + end + + it "reports an ArgumentError" do + expect { + User.process_with_context + }.to have_reported_error(ArgumentError) + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking for specific error class with message + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe User do + it "reports error with specific message" do + expect { + User.process_data + }.to have_reported_error(StandardError, "Processing failed") + end + + it "reports ArgumentError with specific message" do + expect { + User.process_with_context + }.to have_reported_error(ArgumentError, "Invalid input") + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking error messages using regular expressions + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe User do + it "reports errors with a message matching a pattern (any class)" do + expect { + User.process_data + }.to have_reported_error(/Processing/) + end + + it "reports specific class with message matching a pattern" do + expect { + User.process_data + }.to have_reported_error(StandardError, /Processing/) + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Constraining error matches to their attributes using `with_context` + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe User do + it "reports error with specific context" do + expect { + User.process_with_context + }.to have_reported_error.with_context(context: "user_processing") + end + + it "reports error with multiple attributes" do + expect { + User.process_with_context + }.to have_reported_error(ArgumentError).with_context(context: "user_processing", severity: :error) + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking custom error classes + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe User do + it "reports a ValidationError" do + expect { + User.process_custom_error + }.to have_reported_error(User::ValidationError) + end + + it "reports ValidationError with specific message" do + expect { + User.process_custom_error + }.to have_reported_error(User::ValidationError, "Email is invalid") + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Using negation - expecting no errors + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe User do + it "does not report any errors for safe operations" do + expect { + # Safe operation that doesn't report errors + "safe code" + }.not_to have_reported_error + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass diff --git a/lib/rspec/rails/matchers.rb b/lib/rspec/rails/matchers.rb index fb297eabc..36fa5c0b3 100644 --- a/lib/rspec/rails/matchers.rb +++ b/lib/rspec/rails/matchers.rb @@ -20,6 +20,7 @@ module Matchers require 'rspec/rails/matchers/relation_match_array' require 'rspec/rails/matchers/be_valid' require 'rspec/rails/matchers/have_http_status' +require 'rspec/rails/matchers/have_reported_error' require 'rspec/rails/matchers/send_email' if RSpec::Rails::FeatureCheck.has_active_job? diff --git a/lib/rspec/rails/matchers/have_reported_error.rb b/lib/rspec/rails/matchers/have_reported_error.rb new file mode 100644 index 000000000..db5aebcf4 --- /dev/null +++ b/lib/rspec/rails/matchers/have_reported_error.rb @@ -0,0 +1,256 @@ +module RSpec + module Rails + module Matchers + # @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 + + # @api private + class ErrorSubscriber + attr_reader :events + + ErrorEvent = Struct.new(:error, :attributes) + + def initialize + @events = [] + end + + def report(error, **attrs) + @events << ErrorEvent.new(error, attrs.with_indifferent_access) + end + end + + # Matcher class for `have_reported_error`. Should not be instantiated directly. + # + # Provides a way to test that an error was reported to Rails.error. + # + # @api private + # @see RSpec::Rails::Matchers#have_reported_error + class HaveReportedError < RSpec::Rails::Matchers::BaseMatcher + # Uses UndefinedValue as default to distinguish between no argument + # passed vs explicitly passed nil. + # + # @param expected_error_or_message [Class, String, Regexp, nil] + # Error class, message string, or message pattern + # @param expected_message [String, Regexp, nil] + # Expected message when first param is a class + def initialize(expected_error_or_message = UndefinedValue, expected_message = nil) + @attributes = {} + + case expected_error_or_message + when nil, UndefinedValue + @expected_error = nil + @expected_message = expected_message + when String, Regexp + @expected_error = nil + @expected_message = expected_error_or_message + else + @expected_error = expected_error_or_message + @expected_message = expected_message + end + end + + def with_context(expected_attributes) + @attributes.merge!(expected_attributes) + self + end + + def and(_) + raise ArgumentError, "Chaining is not supported" + end + + def matches?(block) + if block.nil? + raise ArgumentError, "this matcher doesn't work with value expectations" + end + + @error_subscriber = ErrorSubscriber.new + ::Rails.error.subscribe(@error_subscriber) + + block.call + + return false if @error_subscriber.events.empty? + return false unless error_matches_expectation? + + return attributes_match_if_specified? + ensure + ::Rails.error.unsubscribe(@error_subscriber) + end + + def supports_block_expectations? + true + end + + def description + base_desc = if @expected_error + "report a #{@expected_error} error" + else + "report an error" + end + + message_desc = if @expected_message + case @expected_message + when Regexp + " with message matching #{@expected_message}" + when String + " with message '#{@expected_message}'" + end + else + "" + end + + attributes_desc = @attributes.empty? ? "" : " with #{@attributes}" + + base_desc + message_desc + attributes_desc + end + + def failure_message + if !@error_subscriber.events.empty? && !@attributes.empty? + event_context = @error_subscriber.events.last.attributes[:context] + unmatched = unmatched_attributes(event_context) + unless unmatched.empty? + return "Expected error attributes to match #{@attributes}, but got these mismatches: #{unmatched} and actual values are #{event_context}" + end + elsif @error_subscriber.events.empty? + return 'Expected the block to report an error, but none was reported.' + elsif actual_error.nil? + reported_errors = @error_subscriber.events.map { |event| "#{event.error.class}: '#{event.error.message}'" }.join(', ') + if @expected_error && @expected_message + return "Expected error to be an instance of #{@expected_error} with message '#{@expected_message}', but got: #{reported_errors}" + elsif @expected_error + return "Expected error to be an instance of #{@expected_error}, but got: #{reported_errors}" + elsif @expected_message.is_a?(Regexp) + return "Expected error message to match #{@expected_message}, but got: #{reported_errors}" + elsif @expected_message.is_a?(String) + return "Expected error message to be '#{@expected_message}', but got: #{reported_errors}" + 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}'" + elsif @expected_message + return "Expected error message to #{@expected_message.is_a?(Regexp) ? "match" : "be" } #{@expected_message}, but got: '#{actual_error.message}'" + else + return "Expected specific error, but got #{actual_error.class} with message: '#{actual_error.message}'" + end + end + end + + def failure_message_when_negated + error_count = @error_subscriber.events.count + error_word = 'error'.pluralize(error_count) + verb = error_count == 1 ? 'has' : 'have' + + "Expected the block not to report any errors, but #{error_count} #{error_word} #{verb} been reported." + end + + private + + def error_matches_expectation? + return true if @expected_error.nil? && @expected_message.nil? && @error_subscriber.events.count.positive? + + @error_subscriber.events.any? do |event| + error_class_matches?(event.error) && error_message_matches?(event.error) + end + end + + def error_class_matches?(error) + @expected_error.nil? || error.is_a?(@expected_error) + end + + # Check if the given error message matches the expected message pattern + def error_message_matches?(error) + return true if @expected_message.nil? + + case @expected_message + when Regexp + error.message&.match(@expected_message) + when String + error.message == @expected_message + else + false + end + end + + def attributes_match_if_specified? + return true if @attributes.empty? + return false unless matching_event + + event_context = matching_event.attributes[:context] + attributes_match?(event_context) + end + + def actual_error + @actual_error ||= matching_event&.error + end + + def matching_event + @matching_event ||= find_matching_event + end + + def find_matching_event + @error_subscriber.events.find do |event| + error_class_matches?(event.error) && error_message_matches?(event.error) + end + end + + def attributes_match?(actual) + @attributes.all? do |key, value| + if value.respond_to?(:matches?) + value.matches?(actual[key]) + else + actual[key] == value + end + end + end + + def unmatched_attributes(actual) + @attributes.reject do |key, value| + if value.respond_to?(:matches?) + value.matches?(actual[key]) + else + actual[key] == value + end + end + end + end + + # @api public + # Passes if the block reports an error to `Rails.error`. + # + # This matcher asserts that ActiveSupport::ErrorReporter has received an error report. + # + # @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 class with message + # expect { Rails.error.report(MyError.new("message")) }.to have_reported_error(MyError, "message") + # + # @example Checking for error with exact message (any class) + # expect { Rails.error.report(StandardError.new("exact message")) }.to have_reported_error("exact message") + # + # @example Checking for error with message pattern (any class) + # expect { Rails.error.report(StandardError.new("test message")) }.to have_reported_error(/test/) + # + # @example Checking for specific error class with message pattern + # expect { Rails.error.report(StandardError.new("test message")) }.to have_reported_error(StandardError, /test/) + # + # @example Checking error attributes + # expect { Rails.error.report(StandardError.new, context: "test") }.to have_reported_error.with_context(context: "test") + # + # @example Negation + # expect { "safe code" }.not_to have_reported_error + # + # @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) + HaveReportedError.new(expected_error_or_message, expected_message) + end + + alias_method :reports_error, :have_reported_error + end + end +end diff --git a/spec/rspec/rails/matchers/have_reported_error_spec.rb b/spec/rspec/rails/matchers/have_reported_error_spec.rb new file mode 100644 index 000000000..68e91dc1d --- /dev/null +++ b/spec/rspec/rails/matchers/have_reported_error_spec.rb @@ -0,0 +1,211 @@ +RSpec.describe "have_reported_error matcher" do + class TestError < StandardError; end + class AnotherTestError < StandardError; end + + it "is aliased as reports_error" do + expect {Rails.error.report(StandardError.new("test error"))}.to reports_error + end + + 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(ArgumentError, "this matcher doesn't work with value expectations") + end + + context "without constraint" do + it "passes when an error is reported" do + expect {Rails.error.report(StandardError.new("test error"))}.to have_reported_error + end + + it "passes when an error is reported with explicit nil argument" do + expect {Rails.error.report(StandardError.new("test error"))}.to have_reported_error(nil) + end + + it "fails when no errors are reported" do + expect { + expect { "no error" }.to have_reported_error + }.to fail_with(/Expected the block to report an error, but none was reported./) + end + + it "fails when no errors are reported with explicit nil argument" do + expect { + expect { "no error" }.to have_reported_error(nil) + }.to fail_with(/Expected the block to report an error, but none was reported./) + end + + it "passes when negated and no errors are reported" do + expect { "no error" }.not_to have_reported_error + end + end + + context "constrained to a specific error class" do + it "passes when an error with the correct class is reported" do + expect { Rails.error.report(TestError.new("test error")) }.to have_reported_error(TestError) + end + + it "fails when an error with the wrong class is reported" do + expect { + expect { + Rails.error.report(AnotherTestError.new("wrong error")) + }.to have_reported_error(TestError) + }.to fail_with(/Expected error to be an instance of TestError, but got: AnotherTestError/) + end + end + + context "constrained to a matching error (class and message)" do + it "passes with an error that matches exactly" do + expect { + Rails.error.report(TestError.new("exact message")) + }.to have_reported_error(TestError, "exact message") + end + + it "passes any error of the same class if no message is specified" do + expect { + Rails.error.report(TestError.new("any message")) + }.to have_reported_error(TestError) + end + + it "fails when the error has different message to the expected" do + expect { + expect { + Rails.error.report(TestError.new("actual message")) + }.to have_reported_error(TestError, "expected message") + }.to fail_with(/Expected error to be an instance of TestError with message 'expected message', but got: TestError/) + end + end + + context "constrained by regex pattern matching" do + it "passes when an error message matches the pattern" do + expect { + Rails.error.report(StandardError.new("error with pattern")) + }.to have_reported_error(StandardError, /with pattern/) + end + + it "fails when no error messages match the pattern" do + expect { + expect { + Rails.error.report(StandardError.new("error without match")) + }.to have_reported_error(StandardError, /different pattern/) + }.to fail_with(/Expected error to be an instance of StandardError with message/) + end + end + + describe "#failure_message" do + it "provides details about mismatched attributes" do + expect { + 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"}/) + end + + it "identifies partial attribute mismatches correctly" do + expect { + expect { + Rails.error.report(StandardError.new("test"), context: { user_id: 123, status: "active", role: "admin" }) + }.to have_reported_error.with_context(user_id: 456, status: "active") # user_id wrong, status correct + }.to fail_with(/got these mismatches: {user_id: 456}/) + end + + it "handles RSpec matcher mismatches in failure messages" do + expect { + expect { + Rails.error.report(StandardError.new("test"), context: { params: { foo: "different" } }) + }.to have_reported_error.with_context(params: a_hash_including(foo: "bar")) + }.to fail_with(/Expected error attributes to match/) + end + + it "shows actual context values when attributes don't match" do + expect { + expect { + Rails.error.report(StandardError.new("test"), context: { user_id: 123, context: "actual" }) + }.to have_reported_error.with_context(user_id: 456) + }.to fail_with(/actual values are {"user_id" => 123, "context" => "actual"}/) + end + end + + describe "#with_context" do + it "passes when attributes match exactly" do + expect { + Rails.error.report(StandardError.new("test"), context: { user_id: 123, context: "test" }) + }.to have_reported_error.with_context(user_id: 123, context: "test") + end + + it "passes with partial attribute matching" do + expect { + Rails.error.report( + StandardError.new("test"), context: { user_id: 123, context: "test", extra: "data" } + ) + }.to have_reported_error.with_context(user_id: 123) + end + + it "passes with hash matching using RSpec matchers" do + expect { + Rails.error.report( + StandardError.new("test"), context: { params: { foo: "bar", baz: "qux" } } + ) + }.to have_reported_error.with_context(params: a_hash_including(foo: "bar")) + end + + it "fails when attributes do not match" do + expect { + 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"}/) + end + + it "fails when no error is reported but attributes are expected" do + expect { + expect { "no error" }.to have_reported_error.with_context(user_id: 123) + }.to fail_with(/Expected the block to report an error, but none was reported./) + end + end + + context "constrained by message only" do + it "passes when any error with exact message is reported" do + expect { + Rails.error.report(StandardError.new("exact message")) + }.to have_reported_error("exact message") + end + + it "passes when any error with message matching pattern is reported" do + expect { + Rails.error.report(AnotherTestError.new("error with pattern")) + }.to have_reported_error(/with pattern/) + end + + it "fails when no error with exact message is reported" do + expect { + 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/) + end + + it "fails when no error with matching pattern is reported" do + expect { + expect { + Rails.error.report(StandardError.new("error without match")) + }.to have_reported_error(/different pattern/) + }.to fail_with(/Expected error message to match .+different pattern.+, but got: StandardError/) + end + end + + describe "integration with actual usage patterns" do + it "works with multiple error reports in a block" do + expect { + Rails.error.report(StandardError.new("first error")) + Rails.error.report(TestError.new("second error")) + }.to have_reported_error(StandardError) + end + + it "works with matcher chaining" do + expect { + expect { + Rails.error.report(TestError.new("test")) + }.to have_reported_error(TestError).and have_reported_error + }.to raise_error(ArgumentError, "Chaining is not supported") + end + end +end