diff --git a/.github/workflows/minitest.yml b/.github/workflows/minitest.yml new file mode 100644 index 0000000..2b4c63a --- /dev/null +++ b/.github/workflows/minitest.yml @@ -0,0 +1,42 @@ +name: Build + +on: + push: + branches: + - master + pull_request: + +jobs: + minitest: + runs-on: ubuntu-latest + env: + BUNDLE_JOBS: 4 + BUNDLE_RETRY: 3 + BUNDLE_GEMFILE: ${{ matrix.gemfile }} + CI: true + strategy: + fail-fast: false + matrix: + ruby: ["3.0"] + gemfile: [ + "gemfiles/rails6.gemfile", + ] + include: + # BLOCKED: https://github.com/zdennis/activerecord-import/issues/736 + # - ruby: "3.0" + # gemfile: "gemfiles/railsmaster.gemfile" + - ruby: "2.6" + gemfile: "gemfiles/rails6.gemfile" + steps: + - uses: actions/checkout@v2 + - name: Install system deps + run: | + sudo apt-get update + sudo apt-get install libsqlite3-dev + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run Minitest + run: | + bundle exec rake test diff --git a/.gitignore b/.gitignore index 74bd648..bd9d7c3 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ Gemfile.local tmp/ .rbnext/ + +gemfiles/*.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index d76662d..242440e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## master +- Add minitest assertions: `assert_event_published`, `refute_event_published`, `assert_async_event_subscriber_enqueued` ([@chriscz][]) + ## 1.0.0 (2021-01-14) - Ruby 2.6+, Rails 6+ and RailsEventStore 2.1+ is required. diff --git a/README.md b/README.md index ab19151..86e5331 100644 --- a/README.md +++ b/README.md @@ -150,20 +150,32 @@ We suggest putting subscribers to the `app/subscribers` folder using the followi You can test subscribers as normal Ruby objects. -**NOTE:** Currently, we provide additional matchers only for RSpec. PRs with Minitest support are welcomed! +**NOTE** To test using minitest include the `ActiveEventStore::TestHelpers` module in your tests. To test that a given subscriber exists, you can use the `have_enqueued_async_subscriber_for` matcher: ```ruby -# for asynchronous subscriptions +# for asynchronous subscriptions (rspec) it "is subscribed to some event" do event = MyEvent.new(some: "data") expect { ActiveEventStore.publish event } .to have_enqueued_async_subscriber_for(MySubscriberService) .with(event) end + +# for asynchronous subscriptions (minitest) +def test_is_subscribed_to_some_event + event = MyEvent.new(some: "data") + + assert_async_event_subscriber_enqueued(MySubscriberService, event: event) do + ActiveEventStore.publish event + end +end ``` +**NOTE** Async event subscribers are queued only after the current transaction has committed so when using `assert_enqued_async_subcriber` in rails +make sure to have `self.use_transactional_fixtures = false` at the top of your test class. + **NOTE:** You must have `rspec-rails` gem in your bundle to use `have_enqueued_async_subscriber_for` matcher. For synchronous subscribers using `have_received` is enough: @@ -183,10 +195,14 @@ end To test event publishing, use `have_published_event` matcher: ```ruby +# rspec expect { subject }.to have_published_event(ProfileCreated).with(user_id: user.id) + +# minitest +assert_event_published(ProfileCreated, with: {user_id: user.id}) { subject } ``` -**NOTE:** `have_published_event` only supports block expectations. +**NOTE:** `have_published_event` and `assert_event_published` only supports block expectations. **NOTE 2** `with` modifier works like `have_attributes` matcher (not `contain_exactly`); you can only specify serializable attributes in `with` (i.e. sync attributes are not supported, 'cause they are not persistent). diff --git a/Rakefile b/Rakefile index 68376fc..e5a10f6 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "rake/testtask" require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) @@ -16,4 +17,11 @@ rescue LoadError task("rubocop:md") {} end -task default: %w[rubocop rubocop:md spec] +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/**/*_test.rb"] + t.warning = false +end + +task default: %w[rubocop rubocop:md spec test] diff --git a/active_event_store.gemspec b/active_event_store.gemspec index cbc0b65..5986fec 100644 --- a/active_event_store.gemspec +++ b/active_event_store.gemspec @@ -31,4 +31,5 @@ Gem::Specification.new do |s| s.add_development_dependency "combustion", ">= 1.1" s.add_development_dependency "rake", ">= 13.0" s.add_development_dependency "rspec-rails", ">= 3.8" + s.add_development_dependency "minitest", "~> 5.0" end diff --git a/lib/active_event_store/test_helper.rb b/lib/active_event_store/test_helper.rb new file mode 100644 index 0000000..9344ae7 --- /dev/null +++ b/lib/active_event_store/test_helper.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "active_event_store/test_helper/event_published_matcher" + +module ActiveEventStore + module TestHelper + extend ActiveSupport::Concern + + included do + include ActiveJob::TestHelper + end + + # Asserts that the given event was published `exactly`, `at_least` or `at_most` number of times + # to a specific `store` `with` a particular hash of attributes. + def assert_event_published(expected_event, store: nil, with: nil, exactly: nil, at_least: nil, at_most: nil, &block) + matcher = EventPublishedMatcher.new( + expected_event, + store: store, + with: with, + exactly: exactly, + at_least: at_least, + at_most: at_most + ) + + if (msg = matcher.matches?(block)) + fail(msg) + end + + matcher.matching_events + end + + # Asserts that the given event was *not* published `exactly`, `at_least` or `at_most` number of times + # to a specific `store` `with` a particular hash of attributes. + def refute_event_published(expected_event, store: nil, with: nil, exactly: nil, at_least: nil, at_most: nil, &block) + matcher = EventPublishedMatcher.new( + expected_event, + store: store, + with: with, + exactly: exactly, + at_least: at_least, + at_most: at_most, + refute: true + ) + + if (msg = matcher.matches?(block)) + fail(msg) + end + end + + def assert_async_event_subscriber_enqueued(subscriber_class, event: nil, queue: "events_subscribers", &block) + subscriber_job = ActiveEventStore::SubscriberJob.for(subscriber_class) + if subscriber_job.nil? + fail("No such async subscriber: #{subscriber_class.name}") + end + + expected_event = event + event_matcher = ->(actual_event) { EventPublishedMatcher.event_matches?(expected_event, expected_event.data, actual_event) } + + expected_args = if expected_event + event_matcher + end + + assert_enqueued_with(job: subscriber_job, queue: queue, args: expected_args) do + ActiveRecord::Base.transaction do + block.call + end + end + end + end +end diff --git a/lib/active_event_store/test_helper/event_published_matcher.rb b/lib/active_event_store/test_helper/event_published_matcher.rb new file mode 100644 index 0000000..883a513 --- /dev/null +++ b/lib/active_event_store/test_helper/event_published_matcher.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +module ActiveEventStore + module TestHelper + class EventPublishedMatcher + attr_reader :attributes, + :matching_events + + def initialize(expected_event_class, store: nil, with: nil, exactly: nil, at_least: nil, at_most: nil, refute: false) + @event_class = expected_event_class + @store = store || ActiveEventStore.event_store + @attributes = with + @refute = refute + + count_expectations = { + exactly: exactly, + at_most: at_most, + at_least: at_least + }.reject { |_, v| v.nil? } + + if count_expectations.length > 1 + raise ArgumentError("Only one of :exactly, :at_least or :at_most can be specified") + elsif count_expectations.length == 0 + @count_expectation_kind = :at_least + @expected_count = 1 + else + @count_expectation_kind = count_expectations.keys.first + @expected_count = count_expectations.values.first + end + end + + def with_published_events(&block) + original_count = @store.read.count + block.call + in_block_events(original_count, @store.read.count) + end + + def matches?(block) + raise ArgumentError, "#{assertion_name} only support block assertions" if block.nil? + + events = with_published_events do + block.call + end + + @matching_events, @unmatching_events = partition_events(events) + + mismatch_message = count_mismatch_message(@matching_events.size) + + if mismatch_message + expectations = [ + "Expected #{mismatch_message} #{@event_class.identifier}" + ] + + expectations << if refute? + report_events = @matching_events + "not to have been published" + else + report_events = @unmatching_events + "to have been published" + end + + expectations << "with attributes #{attributes.inspect}" unless attributes.nil? + + expectations << expectations.pop + ", but" + + expectations << if report_events.any? + report_events.inject("published the following events instead:") do |msg, event| + msg + "\n #{event.inspect}" + end + else + "hasn't published anything" + end + + return expectations.join(" ") + end + + nil + end + + private + + def refute? + @refute + end + + def assertion_name + if refute? + "refute_event_published" + else + "assert_event_published" + end + end + + def negate_on_refute(cond) + if refute? + !cond + else + cond + end + end + + def in_block_events(before_block_count, after_block_count) + count_difference = after_block_count - before_block_count + if count_difference.positive? + @store.read.backward.limit(count_difference).to_a + else + [] + end + end + + # Partitions events into matching and unmatching + def partition_events(events) + events.partition { |e| self.class.event_matches?(@event_class, @attributes, e) } + end + + def count_mismatch_message(actual_count) + case @count_expectation_kind + when :exactly + if negate_on_refute(actual_count != @expected_count) + "exactly #{@expected_count}" + end + when :at_most + if negate_on_refute(actual_count > @expected_count) + "at most #{@expected_count}" + end + when :at_least + if negate_on_refute(actual_count < @expected_count) + "at least #{@expected_count}" + end + else + raise ArgumentError, "Unrecognized expectation kind: #{@count_expectation_kind}" + end + end + + class << self + def event_matches?(event_class, attributes, event) + event_type_matches?(event_class, event) && event_data_matches?(attributes, event) + end + + def event_type_matches?(event_class, event) + event_class.identifier == event.event_type + end + + def event_data_matches?(attributes, event) + (attributes.nil? || attributes.all? { |k, v| v == event.public_send(k) }) + end + end + end + end +end diff --git a/test/active_event_store/test_helper_test.rb b/test/active_event_store/test_helper_test.rb new file mode 100644 index 0000000..42525f6 --- /dev/null +++ b/test/active_event_store/test_helper_test.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "test_helper" +require "active_event_store/test_helper" +require_relative "../../spec/support/test_events" + +class ActiveEventStore::TestHelperTest < Minitest::Test + include ActiveEventStore::TestHelper + attr_reader :event_class, + :event, + :event2 + + module TestSubscriber + class << self + def call(_event) + end + end + end + + def setup + super + @event_class = ActiveEventStore::TestEvent + @test_subscriber = TestSubscriber + @event = event_class.new(user_id: 25, action_type: "birth") + @event2 = event_class.new(user_id: 1, action_type: "death") + end + + def test_assert_event_published + events = assert_event_published(ActiveEventStore::TestEvent) do + ActiveEventStore.publish(event) + end + + assert events.length == 1 + assert_includes events, event + end + + def test_assert_event_published_with_one_attribute + assert_event_published(ActiveEventStore::TestEvent, with: {user_id: 25}) do + ActiveEventStore.publish(event) + end + end + + def test_assert_event_published_with_multiple_attributes + assert_event_published(ActiveEventStore::TestEvent, with: {user_id: 25, action_type: "birth"}) do + ActiveEventStore.publish(event) + end + end + + def test_assert_event_published_with_count + assert_event_published(ActiveEventStore::TestEvent, at_least: 2) do + ActiveEventStore.publish(event) + ActiveEventStore.publish(event2) + end + end + + # == Failure cases + + def test_assert_event_published_with_no_events + e = assert_raises do + assert_event_published(ActiveEventStore::TestEvent, exactly: 1) do + end + end + + assert_match(/exactly 1 test_event to have been published, but hasn't published anything/, e.message) + end + + def test_assert_event_published_with_class_mismatch + e = assert_raises do + assert_event_published(ActiveEventStore::AnotherTestEvent) do + ActiveEventStore.publish(event) + end + end + + assert_match(/at least 1 active_event_store.another_test_event to have been published.*published the following events instead/, e.message) + end + + def test_assert_event_published_with_attribute_mismatch + e = assert_raises do + assert_event_published(ActiveEventStore::TestEvent, with: {user_id: 25, action_type: "death"}) do + ActiveEventStore.publish(event) + end + end + + assert_match(/at least 1 test_event.*with attributes.*:user_id=>25.*:action_type=>"death".*but published(.|[\n])*:user_id=>25.*:action_type=>"birth"/s, e.message) + end + + # == Refute tests + def test_refute_event_published_with_published_event + e = assert_raises do + refute_event_published(ActiveEventStore::TestEvent, with: {user_id: 25, action_type: "birth"}) do + ActiveEventStore.publish(event) + end + end + + assert_match(/at least 1 test_event not.*with attributes.*:user_id=>25.*:action_type=>"birth".*/s, e.message) + end + + def test_refute_event_published_with_attribute_mismatched + refute_event_published(ActiveEventStore::TestEvent, with: {user_id: 25, action_type: "death"}) do + ActiveEventStore.publish(event) + end + end + + def test_refute_event_published_on_no_events + refute_event_published(ActiveEventStore::TestEvent, with: {user_id: 25, action_type: "death"}) do + end + end + + # == async_subscriber tests + def test_assert_async_event_subscriber_with_event + ActiveEventStore.subscribe(@test_subscriber, to: event_class, sync: false) + + assert_async_event_subscriber_enqueued(@test_subscriber) do + ActiveEventStore.publish(event) + end + end + + def test_assert_async_event_subscriber_raises_without_event + ActiveEventStore.subscribe(@test_subscriber, to: event_class, sync: false) + + e = assert_raises Minitest::Assertion do + assert_async_event_subscriber_enqueued(@test_subscriber) {} + end + + assert_match(/No enqueued job found.*job.*ActiveEventStore::TestHelperTest::TestSubscriber::SubscriberJob.*queue.*"events_subscribers"/, e.message) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..bbb703e --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "combustion" + +require "rails_event_store" +require "rails/generators" + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +ENV["RAILS_ENV"] = "test" + +require "bundler" +Bundler.require :default, :test + +begin + require "pry-byebug" +rescue LoadError +end + +FileUtils.rm_rf File.join(__dir__, "../spec/internal", "db", "migrate") + +Dir.chdir(File.join(__dir__, "../spec/internal")) do + Rails::Generators.invoke("rails_event_store_active_record:migration") +end + +begin + Combustion.initialize! :active_record, :active_job do + config.logger = Logger.new(nil) + config.log_level = :fatal + config.active_job.queue_adapter = :test + end +rescue => e + # Fail fast if application couldn't be loaded + $stdout.puts "Failed to load the app: #{e.message}\n#{e.backtrace.take(5).join("\n")}" + exit(1) +end + +require "active_event_store" + +Dir["#{__dir__}/helpers/**/*.rb"].sort.each { |f| require f } +Dir["#{__dir__}/stubs/**/*.rb"].sort.each { |f| require f } + +require "minitest/autorun" + +class Minitest::Test + def teardown + # ActiveEventStore.event_store.reset! + end +end