Skip to content

Commit

Permalink
Merge pull request #2 from chriscz/feature/add-minitest-assertions
Browse files Browse the repository at this point in the history
Implement assertions for Minitest
  • Loading branch information
palkan authored Sep 16, 2021
2 parents d717524 + 9c3df6f commit 968f338
Show file tree
Hide file tree
Showing 10 changed files with 471 additions and 4 deletions.
42 changes: 42 additions & 0 deletions .github/workflows/minitest.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@ Gemfile.local

tmp/
.rbnext/

gemfiles/*.lock
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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).

Expand Down
10 changes: 9 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require "rake/testtask"
require "rspec/core/rake_task"

RSpec::Core::RakeTask.new(:spec)
Expand All @@ -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]
1 change: 1 addition & 0 deletions active_event_store.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
70 changes: 70 additions & 0 deletions lib/active_event_store/test_helper.rb
Original file line number Diff line number Diff line change
@@ -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
150 changes: 150 additions & 0 deletions lib/active_event_store/test_helper/event_published_matcher.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 968f338

Please sign in to comment.