From 3e49ce022952598834d603fbf3ebdf24cd1fcc04 Mon Sep 17 00:00:00 2001 From: Keshav Biswa Date: Sat, 20 Dec 2025 01:31:36 +0530 Subject: [PATCH 1/4] Squashed commit of the following: Add RSpec matchers for Rails 8.1+ EventReporter Introduces matchers for testing ActiveSupport::EventReporter: - have_reported_event: Assert an event was reported - with_payload: Match event payload - with_tags: Match event tags - have_reported_no_event: Assert no matching events - have_reported_events: Assert multiple events - with_debug_event_reporting: Helper to enable debug mode in tests --- lib/rspec/rails/feature_check.rb | 4 + lib/rspec/rails/matchers.rb | 4 + lib/rspec/rails/matchers/event_reporter.rb | 484 ++++++++++++++++++ .../rails/matchers/event_reporter_spec.rb | 399 +++++++++++++++ 4 files changed, 891 insertions(+) create mode 100644 lib/rspec/rails/matchers/event_reporter.rb create mode 100644 spec/rspec/rails/matchers/event_reporter_spec.rb diff --git a/lib/rspec/rails/feature_check.rb b/lib/rspec/rails/feature_check.rb index a19b2cfef..75d199a1c 100644 --- a/lib/rspec/rails/feature_check.rb +++ b/lib/rspec/rails/feature_check.rb @@ -43,6 +43,10 @@ def has_action_mailbox? defined?(::ActionMailbox) end + def has_event_reporter? + defined?(::ActiveSupport::EventReporter) + end + def type_metatag(type) "type: :#{type}" end diff --git a/lib/rspec/rails/matchers.rb b/lib/rspec/rails/matchers.rb index fb297eabc..0a30f64d8 100644 --- a/lib/rspec/rails/matchers.rb +++ b/lib/rspec/rails/matchers.rb @@ -34,3 +34,7 @@ module Matchers if RSpec::Rails::FeatureCheck.has_action_mailbox? require 'rspec/rails/matchers/action_mailbox' end + +if RSpec::Rails::FeatureCheck.has_event_reporter? + require 'rspec/rails/matchers/event_reporter' +end diff --git a/lib/rspec/rails/matchers/event_reporter.rb b/lib/rspec/rails/matchers/event_reporter.rb new file mode 100644 index 000000000..ed9a307d2 --- /dev/null +++ b/lib/rspec/rails/matchers/event_reporter.rb @@ -0,0 +1,484 @@ +# frozen_string_literal: true + +module RSpec + module Rails + module Matchers + # Container module for event reporter matchers. + # + # @api private + module EventReporter + # @api private + # Internal subscriber that collects events during test execution. + module EventCollector + @subscribed = false + @mutex = Mutex.new + + class << self + def emit(event) + event_recorders&.each do |recorder| + recorder << Event.new(event) + end + true + end + + def record + subscribe + events = [] + event_recorders << events + begin + yield + events + ensure + event_recorders.delete_if { |r| events.equal?(r) } + end + end + + private + + def subscribe + return if @subscribed + + @mutex.synchronize do + unless @subscribed + if ActiveSupport.event_reporter + ActiveSupport.event_reporter.subscribe(self) + @subscribed = true + else + raise "No event reporter is configured. Ensure Rails.application is initialized." + end + end + end + end + + def event_recorders + ActiveSupport::IsolatedExecutionState[:rspec_rails_event_reporter_events] ||= [] + end + end + end + + # @api private + # Wraps event data and provides matching logic. + class Event + attr_reader :event_data + + def initialize(event_data) + @event_data = event_data + end + + def inspect + "#{event_data[:name]} (payload: #{event_data[:payload].inspect}, tags: #{event_data[:tags].inspect})" + end + + def matches?(name, payload = nil, tags = nil) + return false if name && name.to_s != event_data[:name] + return false if payload && !matches_payload?(payload) + return false if tags && !matches_tags?(tags) + + true + end + + private + + def matches_payload?(expected_payload) + matches_hash?(expected_payload, :payload, allow_regexp: true) + end + + def matches_tags?(expected_tags) + matches_hash?(expected_tags, :tags, allow_regexp: true) + end + + def matches_hash?(expected, key, allow_regexp:) + actual = event_data[key] + return false unless actual.is_a?(Hash) + + expected.all? do |k, v| + return false unless actual.key?(k) + + actual_value = actual[k] + if allow_regexp && v.is_a?(Regexp) + actual_value.to_s.match?(v) + else + actual_value == v + end + end + end + end + + # @api private + # Base class for event reporter matchers. + class Base < RSpec::Rails::Matchers::BaseMatcher + def initialize + super() + @expected_payload = nil + @expected_tags = nil + end + + def supports_value_expectations? + false + end + + def supports_block_expectations? + true + end + + # @api public + # Specifies the expected payload. + # + # @param payload [Hash] expected payload keys and values + # @return [self] self for chaining + # @raise [ArgumentError] if payload is not a Hash + def with_payload(payload) + require_hash_argument(payload, :with_payload) + @expected_payload = payload + self + end + + # @api public + # Specifies the expected tags (supports Regexp values for matching). + # + # @param tags [Hash] expected tag keys and values (values can be Regexp) + # @return [self] self for chaining + # @raise [ArgumentError] if tags is not a Hash + def with_tags(tags) + require_hash_argument(tags, :with_tags) + @expected_tags = tags + self + end + + private + + def require_hash_argument(value, method_name) + return if value.is_a?(Hash) + + raise ArgumentError, "#{method_name} requires a Hash, got #{value.class}" + end + + def formatted_events + @events.map { |e| " #{e.inspect}" }.join("\n") + end + + def format_event_criteria(name: nil, payload: nil, tags: nil) + parts = [] + parts << "name: #{name.inspect}" if name + parts << "payload: #{payload.inspect}" if payload + parts << "tags: #{tags.inspect}" if tags + parts.join(", ") + end + + def find_matching_event(name: @expected_name, payload: @expected_payload, tags: @expected_tags) + @events.find { |event| event.matches?(name, payload, tags) } + end + end + + # @api private + # + # Matcher class for `have_reported_event`. Should not be instantiated directly. + # + # @see RSpec::Rails::Matchers#have_reported_event + class HaveReportedEvent < Base + def initialize(expected_name) + super() + @expected_name = expected_name + end + + def matches?(block) + @events = EventCollector.record(&block) + + if @events.empty? + @failure_reason = :no_events + return false + end + + @matching_event = find_matching_event + + if @matching_event + true + else + @failure_reason = :no_match + false + end + end + + def failure_message + case @failure_reason + when :no_events + "expected an event to be reported, but there were no events reported" + when :no_match + <<~MSG.chomp + expected an event to be reported matching: + #{expectation_details} + but none of the #{@events.size} reported events matched: + #{formatted_events} + MSG + end + end + + def failure_message_when_negated + if @expected_name + "expected no event matching #{@expected_name.inspect} to be reported, but one was found" + else + "expected no event to be reported, but one was found" + end + end + + def description + desc = "report event" + desc += " #{@expected_name.inspect}" if @expected_name + desc += " with payload #{@expected_payload.inspect}" if @expected_payload + desc += " with tags #{@expected_tags.inspect}" if @expected_tags + desc + end + + private + + def expectation_details + details = [] + details << " name: #{@expected_name.inspect}" if @expected_name + details << " payload: #{@expected_payload.inspect}" if @expected_payload + details << " tags: #{@expected_tags.inspect}" if @expected_tags + details.join("\n") + end + end + + # @api private + # + # Matcher class for `have_reported_no_event`. Should not be instantiated directly. + # + # @see RSpec::Rails::Matchers#have_reported_no_event + class HaveReportedNoEvent < Base + def initialize(expected_name = nil) + super() + @expected_name = expected_name + end + + def matches?(block) + @events = EventCollector.record(&block) + + if has_filters? + @matching_event = find_matching_event + @matching_event.nil? + else + @events.empty? + end + end + + def failure_message + if has_filters? + <<~MSG.chomp + expected no event matching #{match_description} to be reported, but found: + #{@matching_event.inspect} + MSG + else + <<~MSG.chomp + expected no events to be reported, but #{@events.size} events were reported: + #{formatted_events} + MSG + end + end + + def failure_message_when_negated + if has_filters? + "expected an event matching #{match_description} to be reported, but none were found" + else + "expected at least one event to be reported, but none were" + end + end + + def description + if has_filters? + "report no event matching #{match_description}" + else + "report no events" + end + end + + private + + def has_filters? + !!(@expected_name || @expected_payload || @expected_tags) + end + + def match_description + format_event_criteria( + name: @expected_name, + payload: @expected_payload, + tags: @expected_tags + ) + end + end + + # @api private + # + # Matcher class for `have_reported_events`. Should not be instantiated directly. + # + # @see RSpec::Rails::Matchers#have_reported_events + class HaveReportedEvents < Base + def initialize(expected_events) + super() + @expected_events = expected_events + end + + def matches?(block) + @events = EventCollector.record(&block) + + @missing_events = find_missing_events + + if @missing_events.empty? + true + elsif @events.empty? + @failure_reason = :no_events + false + else + @failure_reason = :missing_events + false + end + end + + def failure_message + case @failure_reason + when :no_events + "expected #{@expected_events.size} events to be reported, but there were no events reported" + when :missing_events + <<~MSG.chomp + expected all events to be reported, but some were missing: + #{formatted_missing_events} + reported events: + #{formatted_events} + MSG + end + end + + def failure_message_when_negated + "expected events not to be reported, but all were found" + end + + def description + "report #{@expected_events.size} events" + end + + private + + def find_missing_events + remaining_events = @events.dup + missing = [] + + @expected_events.each do |expected| + match_index = remaining_events.find_index do |event| + event.matches?(expected[:name], expected[:payload], expected[:tags]) + end + + if match_index + remaining_events.delete_at(match_index) + else + missing << expected + end + end + + missing + end + + def formatted_missing_events + @missing_events.map do |e| + " #{format_event_criteria(name: e[:name], payload: e[:payload], tags: e[:tags])}" + end.join("\n") + end + end + end + + # @api public + # Passes if the block reports an event matching the expected name. + # + # @example Basic usage + # expect { Rails.event.notify("user.created", { id: 123 }) } + # .to have_reported_event("user.created") + # + # @example With payload matching + # expect { Rails.event.notify("user.created", { id: 123, name: "John" }) } + # .to have_reported_event("user.created") + # .with_payload(id: 123) + # + # @example With tags matching (supports Regexp) + # expect { + # Rails.event.tagged(request_id: "abc123") do + # Rails.event.notify("user.created", { id: 123 }) + # end + # }.to have_reported_event("user.created") + # .with_tags(request_id: /[a-z0-9]+/) + # + # @param name [String, Symbol] the expected event name + # @return [HaveReportedEvent] + def have_reported_event(name = nil) + EventReporter::HaveReportedEvent.new(name) + end + + # @api public + # Passes if the block reports no events (or no events matching the criteria). + # + # @example Basic usage - no events at all + # expect { }.to have_reported_no_event + # + # @example With specific event name + # expect { Rails.event.notify("other.event", {}) } + # .to have_reported_no_event("user.created") + # + # @example With payload filtering + # expect { Rails.event.notify("user.created", { id: 456 }) } + # .to have_reported_no_event("user.created") + # .with_payload(id: 123) + # + # @param name [String, Symbol, nil] the event name to filter (optional) + # @return [HaveReportedNoEvent] + def have_reported_no_event(name = nil) + EventReporter::HaveReportedNoEvent.new(name) + end + + # @api public + # Passes if the block reports all specified events (order-agnostic). + # + # @example Basic usage + # expect { + # Rails.event.notify("user.created", { id: 123 }) + # Rails.event.notify("email.sent", { to: "user@example.com" }) + # }.to have_reported_events([ + # { name: "user.created", payload: { id: 123 } }, + # { name: "email.sent" } + # ]) + # + # @example With tags matching (supports Regexp) + # expect { + # Rails.event.tagged(request_id: "123") do + # Rails.event.notify("user.created", { id: 123 }) + # Rails.event.notify("email.sent", { to: "user@example.com" }) + # end + # }.to have_reported_events([ + # { name: "user.created", tags: { request_id: /\d+/ } }, + # { name: "email.sent" } + # ]) + # + # @param expected_events [Array] array of expected event specifications + # Each hash can have :name, :payload, and :tags keys + # @return [HaveReportedEvents] + def have_reported_events(expected_events) + EventReporter::HaveReportedEvents.new(expected_events) + end + + # @api public + # Temporarily enables debug mode for the event reporter within the block. + # This allows debug events (reported via `Rails.event.debug`) to be captured + # and tested. + # + # @example Testing debug events + # with_debug_event_reporting do + # expect { + # Rails.event.debug("debug.info", { data: "test" }) + # }.to have_reported_event("debug.info") + # end + # + # @yield The block within which debug mode is enabled + # @return [Object] the result of the block + def with_debug_event_reporting(&block) + ActiveSupport.event_reporter.with_debug(&block) + end + end + end +end diff --git a/spec/rspec/rails/matchers/event_reporter_spec.rb b/spec/rspec/rails/matchers/event_reporter_spec.rb new file mode 100644 index 000000000..0b3810df5 --- /dev/null +++ b/spec/rspec/rails/matchers/event_reporter_spec.rb @@ -0,0 +1,399 @@ +RSpec.describe "have_reported_event", skip: !RSpec::Rails::FeatureCheck.has_event_reporter? do + describe "without name matching" do + it "passes when any event is reported" do + expect { Rails.event.notify("user.created", { id: 123 }) }.to have_reported_event + end + + it "fails when no events are reported" do + expect { + expect { }.to have_reported_event + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /no events reported/) + end + end + + describe "basic name matching" do + it "passes when event is reported" do + expect { Rails.event.notify("user.created", { id: 123 }) }.to have_reported_event("user.created") + end + + it "passes with symbol event name" do + expect { Rails.event.notify(:user_created, { id: 123 }) }.to have_reported_event("user_created") + end + + it "fails when no events are reported" do + expect { + expect { }.to have_reported_event("user.created") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /no events reported/) + end + + it "fails when event name doesn't match" do + expect { + expect { + Rails.event.notify("user.updated", { id: 123 }) + }.to have_reported_event("user.created") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + end + + describe "with payload matching" do + it "passes with matching payload" do + expect { + Rails.event.notify("user.created", { id: 123, name: "John" }) + }.to have_reported_event("user.created").with_payload(id: 123) + end + + it "passes with partial payload matching" do + expect { + Rails.event.notify("user.created", { id: 123, name: "John", email: "john@example.com" }) + }.to have_reported_event("user.created").with_payload(id: 123, name: "John") + end + + it "passes with regex payload matching" do + expect { + Rails.event.notify("user.created", { id: 123, email: "john@example.com" }) + }.to have_reported_event("user.created").with_payload(email: /@example\.com$/) + end + + it "fails when payload doesn't match" do + expect { + expect { + Rails.event.notify("user.created", { id: 456 }) + }.to have_reported_event("user.created").with_payload(id: 123) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "fails when event payload is nil" do + expect { + expect { + Rails.event.notify("user.created", nil) + }.to have_reported_event("user.created").with_payload(id: 123) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "raises ArgumentError when with_payload is called with non-Hash" do + expect { + have_reported_event("user.created").with_payload("invalid") + }.to raise_error(ArgumentError, /with_payload requires a Hash/) + end + end + + describe "with tags matching" do + it "passes with matching tags" do + expect { + Rails.event.tagged(request_id: "abc123") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_event("user.created").with_tags(request_id: "abc123") + end + + it "passes with regex tag matching" do + expect { + Rails.event.tagged(request_id: "abc123") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_event("user.created").with_tags(request_id: /[a-z0-9]+/) + end + + it "passes with partial tag matching" do + expect { + Rails.event.tagged(request_id: "abc123", user_id: 456) do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_event("user.created").with_tags(request_id: "abc123") + end + + it "fails when tags don't match" do + expect { + expect { + Rails.event.tagged(request_id: "xyz") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_event("user.created").with_tags(request_id: "abc123") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "fails when event has no tags" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_tags(request_id: "abc123") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "fails when expected tag key is missing" do + expect { + expect { + Rails.event.tagged(other_key: "value") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_event("user.created").with_tags(request_id: /.*/) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "raises ArgumentError when with_tags is called with non-Hash" do + expect { + have_reported_event("user.created").with_tags("invalid") + }.to raise_error(ArgumentError, /with_tags requires a Hash/) + end + end + + describe "negation" do + it "passes when event is not reported" do + expect { + Rails.event.notify("user.updated", { id: 123 }) + }.not_to have_reported_event("user.created") + end + + it "passes when no events are reported" do + expect { }.not_to have_reported_event("user.created") + end + + it "fails when event is reported" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.not_to have_reported_event("user.created") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected no event matching "user.created" to be reported/) + end + + it "fails when any event is reported and no name specified" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.not_to have_reported_event + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected no event to be reported, but one was found/) + end + end +end + +RSpec.describe "have_reported_no_event", skip: !RSpec::Rails::FeatureCheck.has_event_reporter? do + describe "without filters" do + it "passes when no events are reported" do + expect { }.to have_reported_no_event + end + + it "fails when any event is reported" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_no_event + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected no events to be reported/) + end + end + + describe "with name filter" do + it "passes when specific event is not reported" do + expect { + Rails.event.notify("user.updated", { id: 123 }) + }.to have_reported_no_event("user.created") + end + + it "fails when specific event is reported" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_no_event("user.created") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected no event matching name: "user.created" to be reported/) + end + end + + describe "with payload filter" do + it "passes when no event matches the payload" do + expect { + Rails.event.notify("user.created", { id: 456 }) + }.to have_reported_no_event("user.created").with_payload(id: 123) + end + + it "fails when event matches the payload" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_no_event("user.created").with_payload(id: 123) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected no event matching/) + end + + it "raises ArgumentError when with_payload is called with non-Hash" do + expect { + have_reported_no_event("user.created").with_payload("invalid") + }.to raise_error(ArgumentError, /with_payload requires a Hash/) + end + end + + describe "with tags filter" do + it "passes when no event matches the tags" do + expect { + Rails.event.tagged(request_id: "xyz") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_no_event("user.created").with_tags(request_id: "abc") + end + + it "fails when event matches the tags" do + expect { + expect { + Rails.event.tagged(request_id: "abc") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_no_event("user.created").with_tags(request_id: "abc") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected no event matching/) + end + + it "raises ArgumentError when with_tags is called with non-Hash" do + expect { + have_reported_no_event("user.created").with_tags("invalid") + }.to raise_error(ArgumentError, /with_tags requires a Hash/) + end + end + + describe "negation" do + it "passes when events are reported" do + expect { + Rails.event.notify("user.created", { id: 123 }) + }.not_to have_reported_no_event + end + + it "fails when no events are reported" do + expect { + expect { }.not_to have_reported_no_event + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected at least one event to be reported/) + end + + it "passes when matching event is reported (with name filter)" do + expect { + Rails.event.notify("user.created", { id: 123 }) + }.not_to have_reported_no_event("user.created") + end + + it "fails when no matching event is reported (with name filter)" do + expect { + expect { + Rails.event.notify("user.updated", { id: 123 }) + }.not_to have_reported_no_event("user.created") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected an event matching name: "user.created" to be reported/) + end + end +end + +RSpec.describe "have_reported_events", skip: !RSpec::Rails::FeatureCheck.has_event_reporter? do + describe "basic matching" do + it "passes when no events expected and none reported" do + expect { }.to have_reported_events([]) + end + + it "passes when all events are reported" do + expect { + Rails.event.notify("user.created", { id: 123 }) + Rails.event.notify("email.sent", { to: "user@example.com" }) + }.to have_reported_events([ + { name: "user.created", payload: { id: 123 } }, + { name: "email.sent" } + ]) + end + + it "passes regardless of order" do + expect { + Rails.event.notify("email.sent", { to: "user@example.com" }) + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_events([ + { name: "user.created", payload: { id: 123 } }, + { name: "email.sent" } + ]) + end + + it "fails when no events are reported" do + expect { + expect { }.to have_reported_events([ + { name: "user.created" } + ]) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /no events reported/) + end + + it "fails when some events are missing" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_events([ + { name: "user.created" }, + { name: "email.sent" } + ]) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /some were missing/) + end + end + + describe "with tags matching" do + it "supports tag matching with regex" do + expect { + Rails.event.tagged(request_id: "123") do + Rails.event.notify("user.created", { id: 123 }) + Rails.event.notify("email.sent", { to: "user@example.com" }) + end + }.to have_reported_events([ + { name: "user.created", tags: { request_id: /\d+/ } }, + { name: "email.sent" } + ]) + end + end + + describe "negation" do + it "passes when not all events are reported" do + expect { + Rails.event.notify("user.created", { id: 123 }) + }.not_to have_reported_events([ + { name: "user.created" }, + { name: "email.sent" } + ]) + end + + it "fails when all events are reported" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + Rails.event.notify("email.sent", { to: "user@example.com" }) + }.not_to have_reported_events([ + { name: "user.created" }, + { name: "email.sent" } + ]) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected events not to be reported, but all were found/) + end + end +end + +RSpec.describe "with_debug_event_reporting", skip: !RSpec::Rails::FeatureCheck.has_event_reporter? do + around do |example| + original_debug_mode = ActiveSupport.event_reporter.debug_mode? + example.run + ActiveSupport.event_reporter.debug_mode = original_debug_mode + end + + it "enables debug events within the block" do + with_debug_event_reporting do + expect { + Rails.event.debug("debug.event", { data: "test" }) + }.to have_reported_event("debug.event") + end + end + + it "does not report debug events when debug_mode is disabled" do + ActiveSupport.event_reporter.debug_mode = false + expect { + Rails.event.debug("debug.event", { data: "test" }) + }.to have_reported_no_event("debug.event") + end + + it "reports debug events when debug_mode is enabled via with_debug_event_reporting" do + ActiveSupport.event_reporter.debug_mode = false + with_debug_event_reporting do + expect { + Rails.event.debug("debug.event", { data: "test" }) + }.to have_reported_event("debug.event") + end + end + + it "restores original debug_mode after the block" do + ActiveSupport.event_reporter.debug_mode = false + with_debug_event_reporting do + expect(ActiveSupport.event_reporter.debug_mode?).to be_truthy + end + expect(ActiveSupport.event_reporter.debug_mode?).to be_falsey + end +end From 393e81b3894fbb3cd561ddc18f54c5ed7124db00 Mon Sep 17 00:00:00 2001 From: Keshav Biswa Date: Sun, 21 Dec 2025 23:06:38 +0530 Subject: [PATCH 2/4] Handle Class and event object names in EventReporter matchers --- lib/rspec/rails/matchers/event_reporter.rb | 23 ++++++++++-- .../rails/matchers/event_reporter_spec.rb | 36 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/lib/rspec/rails/matchers/event_reporter.rb b/lib/rspec/rails/matchers/event_reporter.rb index ed9a307d2..7b5e86a8c 100644 --- a/lib/rspec/rails/matchers/event_reporter.rb +++ b/lib/rspec/rails/matchers/event_reporter.rb @@ -70,7 +70,7 @@ def inspect end def matches?(name, payload = nil, tags = nil) - return false if name && name.to_s != event_data[:name] + return false if name && resolve_name(name) != event_data[:name] return false if payload && !matches_payload?(payload) return false if tags && !matches_tags?(tags) @@ -79,6 +79,17 @@ def matches?(name, payload = nil, tags = nil) private + def resolve_name(name) + case name + when String, Symbol + name.to_s + when Class + name.name + else + name.class.name + end + end + def matches_payload?(expected_payload) matches_hash?(expected_payload, :payload, allow_regexp: true) end @@ -88,7 +99,7 @@ def matches_tags?(expected_tags) end def matches_hash?(expected, key, allow_regexp:) - actual = event_data[key] + actual = normalize_to_hash(event_data[key]) return false unless actual.is_a?(Hash) expected.all? do |k, v| @@ -102,6 +113,14 @@ def matches_hash?(expected, key, allow_regexp:) end end end + + def normalize_to_hash(value) + if value.respond_to?(:serialize) + value.serialize + else + value + end + end end # @api private diff --git a/spec/rspec/rails/matchers/event_reporter_spec.rb b/spec/rspec/rails/matchers/event_reporter_spec.rb index 0b3810df5..31185c674 100644 --- a/spec/rspec/rails/matchers/event_reporter_spec.rb +++ b/spec/rspec/rails/matchers/event_reporter_spec.rb @@ -1,3 +1,18 @@ +module TestEvents + class UserCreated + attr_reader :id, :name + + def initialize(id:, name:) + @id = id + @name = name + end + + def serialize + { id: @id, name: @name } + end + end +end + RSpec.describe "have_reported_event", skip: !RSpec::Rails::FeatureCheck.has_event_reporter? do describe "without name matching" do it "passes when any event is reported" do @@ -20,6 +35,20 @@ expect { Rails.event.notify(:user_created, { id: 123 }) }.to have_reported_event("user_created") end + it "passes with class-based event name" do + event = TestEvents::UserCreated.new(id: 123, name: "John") + expect { + Rails.event.notify(event) + }.to have_reported_event(TestEvents::UserCreated) + end + + it "passes with event object instance as name parameter" do + event = TestEvents::UserCreated.new(id: 123, name: "John") + expect { + Rails.event.notify(event) + }.to have_reported_event(event) + end + it "fails when no events are reported" do expect { expect { }.to have_reported_event("user.created") @@ -54,6 +83,13 @@ }.to have_reported_event("user.created").with_payload(email: /@example\.com$/) end + it "passes with event object payload via serialize" do + event = TestEvents::UserCreated.new(id: 123, name: "John") + expect { + Rails.event.notify(event) + }.to have_reported_event(TestEvents::UserCreated).with_payload(id: 123) + end + it "fails when payload doesn't match" do expect { expect { From 1472c31d64c7836b87e8a60a8cfb856fdd53414a Mon Sep 17 00:00:00 2001 From: Keshav Biswa Date: Mon, 22 Dec 2025 00:21:41 +0530 Subject: [PATCH 3/4] Add with_context support and Cucumber documentation for EventReporter matchers --- .../have_reported_event_matcher.feature | 167 ++++++++++++++++++ .../step_definitions/additional_cli_steps.rb | 6 + lib/rspec/rails/matchers/event_reporter.rb | 47 ++++- .../rails/matchers/event_reporter_spec.rb | 52 ++++++ 4 files changed, 263 insertions(+), 9 deletions(-) create mode 100644 features/matchers/have_reported_event_matcher.feature diff --git a/features/matchers/have_reported_event_matcher.feature b/features/matchers/have_reported_event_matcher.feature new file mode 100644 index 000000000..e6406f831 --- /dev/null +++ b/features/matchers/have_reported_event_matcher.feature @@ -0,0 +1,167 @@ +Feature: `have_reported_event` matcher + + The `have_reported_event` matcher is used to check if an event was reported + via Rails' EventReporter (Rails 8.1+). + + Background: + Given event reporter is available + + Scenario: Checking event name + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "matches with event name" do + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created") + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking event payload + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "matches with payload" do + expect { + Rails.event.notify("user.created", { id: 123, name: "John" }) + }.to have_reported_event("user.created").with_payload(id: 123) + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking event tags + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "matches with tags" do + expect { + Rails.event.tagged(source: "api") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_event("user.created").with_tags(source: "api") + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking event context + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "matches with context" do + Rails.event.set_context(request_id: "abc123") + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_context(request_id: "abc123") + Rails.event.clear_context + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking event with class-based event object + Given a file named "app/events/user_created_event.rb" with: + """ruby + class UserCreatedEvent + attr_reader :id, :name + + def initialize(id:, name:) + @id = id + @name = name + end + + def serialize + { id: @id, name: @name } + end + end + """ + And a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "matches with event class" do + event = UserCreatedEvent.new(id: 123, name: "John") + expect { + Rails.event.notify(event) + }.to have_reported_event(UserCreatedEvent).with_payload(id: 123) + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Using `have_reported_events` for multiple events + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "matches multiple events regardless of order" do + expect { + Rails.event.notify("user.created", { id: 123 }) + Rails.event.notify("email.sent", { to: "john@example.com" }) + }.to have_reported_events([ + { name: "email.sent" }, + { name: "user.created" } + ]) + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Using `have_reported_no_event` to check no events reported + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "passes when no events are reported" do + expect { + # no events + }.to have_reported_no_event + end + + it "passes when specific event is not reported" do + expect { + Rails.event.notify("user.updated", { id: 123 }) + }.to have_reported_no_event("user.created") + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Using `with_debug_event_reporting` for debug events + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "captures debug events within the block" do + with_debug_event_reporting do + expect { + Rails.event.notify("debug.trace", { step: 1 }) + }.to have_reported_event("debug.trace") + end + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass diff --git a/features/step_definitions/additional_cli_steps.rb b/features/step_definitions/additional_cli_steps.rb index 839bd145b..cf3fe201e 100644 --- a/features/step_definitions/additional_cli_steps.rb +++ b/features/step_definitions/additional_cli_steps.rb @@ -43,3 +43,9 @@ pending "Action Mailbox is not available" end end + +Given /event reporter is available/ do + unless RSpec::Rails::FeatureCheck.has_event_reporter? + pending "Event Reporter is not available" + end +end diff --git a/lib/rspec/rails/matchers/event_reporter.rb b/lib/rspec/rails/matchers/event_reporter.rb index 7b5e86a8c..211763919 100644 --- a/lib/rspec/rails/matchers/event_reporter.rb +++ b/lib/rspec/rails/matchers/event_reporter.rb @@ -66,13 +66,14 @@ def initialize(event_data) end def inspect - "#{event_data[:name]} (payload: #{event_data[:payload].inspect}, tags: #{event_data[:tags].inspect})" + "#{event_data[:name]} (payload: #{event_data[:payload].inspect}, tags: #{event_data[:tags].inspect}, context: #{event_data[:context].inspect})" end - def matches?(name, payload = nil, tags = nil) + def matches?(name, payload = nil, tags = nil, context = nil) return false if name && resolve_name(name) != event_data[:name] return false if payload && !matches_payload?(payload) return false if tags && !matches_tags?(tags) + return false if context && !matches_context?(context) true end @@ -98,6 +99,10 @@ def matches_tags?(expected_tags) matches_hash?(expected_tags, :tags, allow_regexp: true) end + def matches_context?(expected_context) + matches_hash?(expected_context, :context, allow_regexp: true) + end + def matches_hash?(expected, key, allow_regexp:) actual = normalize_to_hash(event_data[key]) return false unless actual.is_a?(Hash) @@ -130,6 +135,7 @@ def initialize super() @expected_payload = nil @expected_tags = nil + @expected_context = nil end def supports_value_expectations? @@ -164,6 +170,18 @@ def with_tags(tags) self end + # @api public + # Specifies the expected context + # + # @param context [Hash] expected context keys and values (values can be regex) + # @return [self] self for chaining + # @raise [ArgumentError] if context is not a Hash + def with_context(context) + require_hash_argument(context, :with_context) + @expected_context = context + self + end + private def require_hash_argument(value, method_name) @@ -176,16 +194,17 @@ def formatted_events @events.map { |e| " #{e.inspect}" }.join("\n") end - def format_event_criteria(name: nil, payload: nil, tags: nil) + def format_event_criteria(name: nil, payload: nil, tags: nil, context: nil) parts = [] parts << "name: #{name.inspect}" if name parts << "payload: #{payload.inspect}" if payload parts << "tags: #{tags.inspect}" if tags + parts << "context: #{context.inspect}" if context parts.join(", ") end - def find_matching_event(name: @expected_name, payload: @expected_payload, tags: @expected_tags) - @events.find { |event| event.matches?(name, payload, tags) } + def find_matching_event(name: @expected_name, payload: @expected_payload, tags: @expected_tags, context: @expected_context) + @events.find { |event| event.matches?(name, payload, tags, context) } end end @@ -245,6 +264,7 @@ def description desc += " #{@expected_name.inspect}" if @expected_name desc += " with payload #{@expected_payload.inspect}" if @expected_payload desc += " with tags #{@expected_tags.inspect}" if @expected_tags + desc += " with context #{@expected_context.inspect}" if @expected_context desc end @@ -255,6 +275,7 @@ def expectation_details details << " name: #{@expected_name.inspect}" if @expected_name details << " payload: #{@expected_payload.inspect}" if @expected_payload details << " tags: #{@expected_tags.inspect}" if @expected_tags + details << " context: #{@expected_context.inspect}" if @expected_context details.join("\n") end end @@ -314,14 +335,15 @@ def description private def has_filters? - !!(@expected_name || @expected_payload || @expected_tags) + !!(@expected_name || @expected_payload || @expected_tags || @expected_context) end def match_description format_event_criteria( name: @expected_name, payload: @expected_payload, - tags: @expected_tags + tags: @expected_tags, + context: @expected_context ) end end @@ -383,7 +405,7 @@ def find_missing_events @expected_events.each do |expected| match_index = remaining_events.find_index do |event| - event.matches?(expected[:name], expected[:payload], expected[:tags]) + event.matches?(expected[:name], expected[:payload], expected[:tags], expected[:context]) end if match_index @@ -398,7 +420,7 @@ def find_missing_events def formatted_missing_events @missing_events.map do |e| - " #{format_event_criteria(name: e[:name], payload: e[:payload], tags: e[:tags])}" + " #{format_event_criteria(name: e[:name], payload: e[:payload], tags: e[:tags], context: e[:context])}" end.join("\n") end end @@ -424,6 +446,13 @@ def formatted_missing_events # }.to have_reported_event("user.created") # .with_tags(request_id: /[a-z0-9]+/) # + # @example With context matching + # Rails.event.set_context(request_id: "abc123") + # expect { + # Rails.event.notify("user.created", { id: 123 }) + # }.to have_reported_event("user.created") + # .with_context(request_id: /[a-z0-9]+/) + # # @param name [String, Symbol] the expected event name # @return [HaveReportedEvent] def have_reported_event(name = nil) diff --git a/spec/rspec/rails/matchers/event_reporter_spec.rb b/spec/rspec/rails/matchers/event_reporter_spec.rb index 31185c674..dd21ab993 100644 --- a/spec/rspec/rails/matchers/event_reporter_spec.rb +++ b/spec/rspec/rails/matchers/event_reporter_spec.rb @@ -173,6 +173,58 @@ def serialize end end + describe "with context matching" do + around do |example| + example.run + ensure + Rails.event.clear_context + end + + it "passes with matching context" do + Rails.event.set_context(request_id: "abc123") + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_context(request_id: "abc123") + end + + it "passes with regex context matching" do + Rails.event.set_context(request_id: "abc123") + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_context(request_id: /[a-z0-9]+/) + end + + it "passes with partial context matching" do + Rails.event.set_context(request_id: "abc123", user_id: 456) + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_context(request_id: "abc123") + end + + it "fails when context doesn't match" do + Rails.event.set_context(request_id: "xyz") + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_context(request_id: "abc123") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "fails when event has no context" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_context(request_id: "abc123") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "raises ArgumentError when with_context is called with non-Hash" do + expect { + have_reported_event("user.created").with_context("invalid") + }.to raise_error(ArgumentError, /with_context requires a Hash/) + end + end + describe "negation" do it "passes when event is not reported" do expect { From a2ca892795c21c8841c0558345e5e6db41781d6f Mon Sep 17 00:00:00 2001 From: Keshav Biswa Date: Mon, 22 Dec 2025 12:07:30 +0530 Subject: [PATCH 4/4] Add private api for yard --- lib/rspec/rails/matchers/event_reporter.rb | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/rspec/rails/matchers/event_reporter.rb b/lib/rspec/rails/matchers/event_reporter.rb index 211763919..a1a3b290d 100644 --- a/lib/rspec/rails/matchers/event_reporter.rb +++ b/lib/rspec/rails/matchers/event_reporter.rb @@ -14,6 +14,8 @@ module EventCollector @mutex = Mutex.new class << self + # @api private + # Receives events from the Rails EventReporter subscriber. def emit(event) event_recorders&.each do |recorder| recorder << Event.new(event) @@ -21,6 +23,8 @@ def emit(event) true end + # @api private + # Records events emitted during the block execution. def record subscribe events = [] @@ -59,12 +63,16 @@ def event_recorders # @api private # Wraps event data and provides matching logic. class Event + # @api private + # Returns the raw event data hash. attr_reader :event_data def initialize(event_data) @event_data = event_data end + # @api private + # Returns a human-readable representation of the event. def inspect "#{event_data[:name]} (payload: #{event_data[:payload].inspect}, tags: #{event_data[:tags].inspect}, context: #{event_data[:context].inspect})" end @@ -237,6 +245,8 @@ def matches?(block) end end + # @api private + # Returns the failure message when the expectation is not met. def failure_message case @failure_reason when :no_events @@ -251,6 +261,8 @@ def failure_message end end + # @api private + # Returns the failure message when the negated expectation is not met. def failure_message_when_negated if @expected_name "expected no event matching #{@expected_name.inspect} to be reported, but one was found" @@ -259,6 +271,8 @@ def failure_message_when_negated end end + # @api private + # Returns a description of the matcher. def description desc = "report event" desc += " #{@expected_name.inspect}" if @expected_name @@ -302,6 +316,8 @@ def matches?(block) end end + # @api private + # Returns the failure message when the expectation is not met. def failure_message if has_filters? <<~MSG.chomp @@ -316,6 +332,8 @@ def failure_message end end + # @api private + # Returns the failure message when the negated expectation is not met. def failure_message_when_negated if has_filters? "expected an event matching #{match_description} to be reported, but none were found" @@ -324,6 +342,8 @@ def failure_message_when_negated end end + # @api private + # Returns a description of the matcher. def description if has_filters? "report no event matching #{match_description}" @@ -375,6 +395,8 @@ def matches?(block) end end + # @api private + # Returns the failure message when the expectation is not met. def failure_message case @failure_reason when :no_events @@ -389,10 +411,14 @@ def failure_message end end + # @api private + # Returns the failure message when the negated expectation is not met. def failure_message_when_negated "expected events not to be reported, but all were found" end + # @api private + # Returns a description of the matcher. def description "report #{@expected_events.size} events" end