From 5cfe5e639a95ea6067a2e5aca97ccbbb36abb1d3 Mon Sep 17 00:00:00 2001 From: Nicholas La Roux Date: Thu, 29 Aug 2024 20:52:08 +0900 Subject: [PATCH] Add ActiveSupport::Testing::NotificationAssertions test helper module Co-authored-by: Yishu See Co-authored-by: Sean Doyle --- activesupport/CHANGELOG.md | 4 + activesupport/lib/active_support/test_case.rb | 2 + .../testing/notification_assertions.rb | 80 ++++++++++++++ .../testing/notification_assertions_test.rb | 100 ++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 activesupport/lib/active_support/testing/notification_assertions.rb create mode 100644 activesupport/test/testing/notification_assertions_test.rb diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 366cad45e3c81..c177ba74e385d 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,7 @@ +* Add `ActiveSupport::Testing::NotificationAssertions` module to help with testing `ActiveSupport::Notifications`. + + *Nicholas La Roux*, *Yishu See*, *Sean Doyle* + * `ActiveSupport::CurrentAttributes#attributes` now will return a new hash object on each call. Previously, the same hash object was returned each time that method was called. diff --git a/activesupport/lib/active_support/test_case.rb b/activesupport/lib/active_support/test_case.rb index bd2847fc4fa40..68b9bfa447959 100644 --- a/activesupport/lib/active_support/test_case.rb +++ b/activesupport/lib/active_support/test_case.rb @@ -15,6 +15,7 @@ require "active_support/testing/file_fixtures" require "active_support/testing/parallelization" require "active_support/testing/parallelize_executor" +require "active_support/testing/notification_assertions" require "concurrent/utility/processor_counter" module ActiveSupport @@ -146,6 +147,7 @@ def parallelize_teardown(&block) prepend ActiveSupport::Testing::TestsWithoutAssertions include ActiveSupport::Testing::Assertions include ActiveSupport::Testing::ErrorReporterAssertions + include ActiveSupport::Testing::NotificationAssertions include ActiveSupport::Testing::Deprecation include ActiveSupport::Testing::ConstantStubbing include ActiveSupport::Testing::TimeHelpers diff --git a/activesupport/lib/active_support/testing/notification_assertions.rb b/activesupport/lib/active_support/testing/notification_assertions.rb new file mode 100644 index 0000000000000..bbf9de0441cc5 --- /dev/null +++ b/activesupport/lib/active_support/testing/notification_assertions.rb @@ -0,0 +1,80 @@ + +# typed: true +# frozen_string_literal: true + +module ActiveSupport + module Testing + module NotificationAssertions + # Assert a notification was emitted with a given +pattern+ and optional +payload+. + # + # You can assert that a notification was emitted by passing a pattern, which accepts + # either a string or regexp, an optional payload, and a block. While the block + # is executed, if a matching notification is emitted, the assertion will pass. + # + # assert_notification("post.submitted", title: "Cool Post") do + # post.submit(title: "Cool Post") # => emits matching notification + # end + # + def assert_notification(pattern, payload = nil, &block) + notifications = capture_notifications(pattern, &block) + assert_not_empty(notifications, "No #{pattern} notifications were found") + + return if payload.nil? + + notification = notifications.find { |notification| notification.payload == payload } + assert_not_nil(notification, "No #{pattern} notification with payload #{payload} was found") + end + + # Assert the number of notifications emitted with a given +pattern+. + # + # You can assert the number of notifications emitted by passing a pattern, which accepts + # either a string or regexp, a count, and a block. While the block is executed, + # the number of matching notifications emitted will be counted. After the block's + # execution completes, the assertion will pass if the count matches. + # + # assert_notifications_count("post.submitted", 1) do + # post.submit(title: "Cool Post") # => emits matching notification + # end + # + def assert_notifications_count(pattern, count, &block) + actual_count = capture_notifications(pattern, &block).count + assert_equal(count, actual_count, "Expected #{count} instead of #{actual_count} notifications for #{pattern}") + end + + # Assert no notifications were emitted for a given +pattern+. + # + # You can assert no notifications were emitted by passing a pattern, which accepts + # either a string or regexp, and a block. While the block is executed, if no + # matching notifications are emitted, the assertion will pass. + # + # assert_no_notifications("post.submitted") do + # post.destroy # => emits non-matching notification + # end + # + def assert_no_notifications(pattern = nil, &block) + notifications = capture_notifications(pattern, &block) + error_message = if pattern + "Expected no notifications for #{pattern} but found #{notifications.size}" + else + "Expected no notifications but found #{notifications.size}" + end + assert_empty(notifications, error_message) + end + + # Capture emitted notifications, optionally filtered by a +pattern+. + # + # You can capture emitted notifications, optionally filtered by a pattern, + # which accepts either a string or regexp, and a block. + # + # notifications = capture_notifications("post.submitted") do + # post.submit(title: "Cool Post") # => emits matching notification + # end + # + def capture_notifications(pattern = nil, &block) + notifications = [] + ActiveSupport::Notifications.subscribed(->(n) { notifications << n }, pattern, &block) + notifications + end + end + end +end diff --git a/activesupport/test/testing/notification_assertions_test.rb b/activesupport/test/testing/notification_assertions_test.rb new file mode 100644 index 0000000000000..105545a371ad9 --- /dev/null +++ b/activesupport/test/testing/notification_assertions_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require_relative "../abstract_unit" +require "active_support/testing/notification_assertions" + +module ActiveSupport + module Testing + class NotificationAssertionsTest < ActiveSupport::TestCase + include NotificationAssertions + + def test_assert_notification + assert_notification("post.submitted", title: "Cool Post") do + ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post") + end + + assert_notification("post.submitted") do # payload omitted + ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post") + end + + assert_raises(Minitest::Assertion, match: /No post.submitted notifications were found/) do + assert_notification("post.submitted", title: "Cool Post") { nil } # no notifications + end + + match = if RUBY_VERSION >= "3.4" + /No post.submitted notification with payload {title: "Cool Post"} was found/ + else + /No post.submitted notification with payload {:title=>"Cool Post"} was found/ + end + assert_raises(Minitest::Assertion, match:) do + assert_notification("post.submitted", title: "Cool Post") do + ActiveSupport::Notifications.instrument("post.submitted", title: "Cooler Post") + end + end + end + + def test_assert_notifications_count + assert_notifications_count("post.submitted", 1) do + ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post") + end + + assert_raises(Minitest::Assertion, match: /Expected 1 instead of 2 notifications for post.submitted/) do + assert_notifications_count("post.submitted", 1) do + ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post") + ActiveSupport::Notifications.instrument("post.submitted", title: "Cooler Post") + end + end + + assert_raises(Minitest::Assertion, match: /Expected 1 instead of 0 notifications for post.submitted/) do + assert_notifications_count("post.submitted", 1) { nil } # no notifications + end + end + + def test_assert_no_notifications + assert_no_notifications("post.submitted") { nil } # no notifications + + assert_raises(Minitest::Assertion, match: /Expected no notifications for post.submitted but found 1/) do + assert_no_notifications("post.submitted") do + ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post") + end + end + + assert_raises(Minitest::Assertion, match: /Expected no notifications but found 1/) do + assert_no_notifications do + ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post") + end + end + end + + def test_capture_notifications + notifications = capture_notifications("post.submitted") do # string pattern + ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post") + end + + assert_equal(1, notifications.size) + assert_equal("post.submitted", notifications.first.name) + assert_equal({ title: "Cool Post" }, notifications.first.payload) + + notifications = capture_notifications(/post\./) do # regexp pattern + ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post") + end + + assert_equal(1, notifications.size) + assert_equal("post.submitted", notifications.first.name) + assert_equal({ title: "Cool Post" }, notifications.first.payload) + + notifications = capture_notifications do # no pattern + ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post") + end + + assert_equal(1, notifications.size) + assert_equal("post.submitted", notifications.first.name) + assert_equal({ title: "Cool Post" }, notifications.first.payload) + + notifications = capture_notifications("post.submitted") { nil } # no notifications + + assert_empty(notifications) + end + end + end +end