Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement assertions for Minitest #2

Merged
merged 7 commits into from
Sep 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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