Skip to content

Commit

Permalink
Add enqueue_sidekiq_job block matcher
Browse files Browse the repository at this point in the history
Fixes #170
  • Loading branch information
pirj committed Feb 27, 2021
1 parent ccce13c commit c151095
Show file tree
Hide file tree
Showing 6 changed files with 350 additions and 1 deletion.
2 changes: 2 additions & 0 deletions CHANGES.md
@@ -1,3 +1,5 @@
* Add new enqueue_sidekiq_job block matcher(pirj #???)

3.1.0
---
* Add support for latest ruby and Rails 5 (coding-bunny #156)
Expand Down
36 changes: 36 additions & 0 deletions README.md
Expand Up @@ -52,6 +52,7 @@ end
* [be_processed_in](#be_processed_in)
* [be_retryable](#be_retryable)
* [be_unique](#be_unique)
* [enqueue_sidekiq_job](#enqueue_sidekiq_job)
* [have_enqueued_sidekiq_job](#have_enqueued_sidekiq_job)

### be_delayed
Expand Down Expand Up @@ -136,6 +137,41 @@ it { is_expected.to be_expired_in 1.hour }
it { is_expected.to_not be_expired_in 2.hours }
```

### enqueue_sidekiq_job
*Checks if a certain job was enqueued in a block*

#### Testing scheduled jobs
*Use chainable matchers `#at` and `#in`*
```ruby
time = 5.minutes.from_now
expect { AwesomeWorker.perform_at(time) }
.to enqueue_sidekiq_job(AwesomeWorker).at(time)
```
```ruby
interval = 5.minutes
expect { AwesomeWorker.perform_in(interval) }
.to enqueue_sidekiq_job(AwesomeWorker).in(5.minutes)
```

#### Specifying arguments

```ruby
expect { AwesomeWorker.perform_async(42, 'David')
.to enqueue_sidekiq_job(AwesomeWorker).with(42, 'David')
```

## Example matcher usage
```ruby
require 'spec_helper'

describe User do
it 'enqueues welcome email job' do
expect { User.create! }
.to enqueue_sidekiq_job(WelcomeUserWorker)
end
end
```

### have_enqueued_sidekiq_job
*Describes that there should be an enqueued job with the specified arguments*

Expand Down
1 change: 1 addition & 0 deletions lib/rspec/sidekiq/matchers.rb
Expand Up @@ -4,6 +4,7 @@
require 'rspec/sidekiq/matchers/be_processed_in'
require 'rspec/sidekiq/matchers/be_retryable'
require 'rspec/sidekiq/matchers/be_unique'
require 'rspec/sidekiq/matchers/enqueue_sidekiq_job'
require 'rspec/sidekiq/matchers/have_enqueued_job'
require 'rspec/sidekiq/matchers/save_backtrace'

Expand Down
106 changes: 106 additions & 0 deletions lib/rspec/sidekiq/matchers/enqueue_sidekiq_job.rb
@@ -0,0 +1,106 @@
module RSpec
module Sidekiq
module Matchers
def enqueue_sidekiq_job(worker_class)
EnqueuedSidekiqJob.new(worker_class)
end

class EnqueuedSidekiqJob
include RSpec::Matchers::Composable

def initialize(worker_class)
@worker_class = worker_class
end

def with(*expected_arguments, **kwargs)
fail 'keyword arguments serialization is not supported by Sidekiq' unless kwargs.empty?

@expected_arguments = expected_arguments
self
end

def at(timestamp)
fail 'setting expecations with both `at` and `in` is not supported' if @expected_in

@expected_at = timestamp
self
end

def in(interval)
fail 'setting expecations with both `at` and `in` is not supported' if @expected_at

@expected_in = interval
self
end

def matches?(block)
filter(enqueued_in_block(block)).one?
end

def does_not_match?(block)
filter(enqueued_in_block(block)).none?
end

def failure_message
message = ["expected to enqueue #{worker_class} job"]
message << " arguments: #{expected_arguments}" if expected_arguments
message << " in: #{expected_in.inspect}" if expected_in
message << " at: #{expected_at}" if expected_at
message.join("\n")
end

def failure_message_when_negated
message = ["expected not to enqueue #{worker_class} job"]
message << " arguments: #{expected_arguments}" if expected_arguments
message << " in: #{expected_in.inspect}" if expected_in
message << " at: #{expected_at}" if expected_at
message.join("\n")
end

def supports_block_expectations?
true
end

def supports_value_expectations?
false
end

private

def enqueued_in_block(block)
before = @worker_class.jobs.dup
block.call
@worker_class.jobs - before
end

def filter(jobs)
jobs = jobs.select { |job| timestamps_match_rounded_to_seconds?(expected_at, job['at']) } if expected_at
jobs = jobs.select { |job| intervals_match_rounded_to_seconds?(expected_in, job['at']) } if expected_in
jobs = jobs.select { |job| values_match?(expected_arguments, job['args']) } if expected_arguments
jobs
end

# Due to zero nsec precision of `Time.now` (and therefore `5.minutes.from_now`) on
# some platforms, and lossy Sidekiq serialization that uses `.to_f` on timestamps,
# values won't match unless rounded.
# Rounding to whole seconds is sub-optimal but simple.
def timestamps_match_rounded_to_seconds?(expected, actual)
return false if actual.nil?

actual_time = Time.at(actual)
values_match?(expected_at, actual_time) ||
expected_at.to_i == actual_time.to_i
end

def intervals_match_rounded_to_seconds?(expected, actual)
return false if actual.nil?

actual_time = Time.at(actual)
expected_in.from_now.to_i == actual_time.to_i
end

attr_reader :worker_class, :expected_arguments, :expected_at, :expected_in
end
end
end
end
3 changes: 2 additions & 1 deletion rspec-sidekiq.gemspec
Expand Up @@ -11,7 +11,8 @@ Gem::Specification.new do |s|
s.description = 'Simple testing of Sidekiq jobs via a collection of matchers and helpers'
s.license = 'MIT'

s.add_dependency 'rspec-core', '~> 3.0', '>= 3.0.0'
s.add_dependency 'rspec-core', '~> 3.0'
s.add_dependency 'rspec-expectations', '~> 3.0'
s.add_dependency 'sidekiq', '>= 2.4.0'

s.add_development_dependency 'rspec'
Expand Down
203 changes: 203 additions & 0 deletions spec/rspec/sidekiq/matchers/enqueue_sidekiq_job_spec.rb
@@ -0,0 +1,203 @@
require 'spec_helper'

RSpec.describe RSpec::Sidekiq::Matchers::EnqueuedSidekiqJob do
let(:worker) do
Class.new do
include ::Sidekiq::Worker
end
end

let(:another_worker) do
Class.new do
include ::Sidekiq::Worker
end
end

it 'raises ArgumentError when used in value expectation' do
expect {
expect(worker.perform_async).to enqueue_sidekiq_job(worker)
}.to raise_error
end

it 'fails when no worker class is specified' do
expect {
expect { worker.perform_async }.to enqueue_sidekiq_job
}.to raise_error(ArgumentError)
end

it 'passes' do
expect { worker.perform_async }
.to enqueue_sidekiq_job(worker)
end

it 'fails when negated and job is enqueued' do
expect {
expect { worker.perform_async }.not_to enqueue_sidekiq_job(worker)
}.to raise_error(/expected not to enqueue/)
end

context 'when no jobs were enqueued' do
it 'fails' do
expect {
expect { } # nop
.to enqueue_sidekiq_job(worker)
}.to raise_error(/expected to enqueue/)
end

it 'passes with negation' do
expect { } # nop
.not_to enqueue_sidekiq_job(worker)
end
end

context 'with another worker' do
it 'fails' do
expect {
expect { worker.perform_async }
.to enqueue_sidekiq_job(another_worker)
}.to raise_error(/expected to enqueue/)
end

it 'passes with negation' do
expect { worker.perform_async }
.not_to enqueue_sidekiq_job(another_worker)
end
end

it 'counts only jobs enqueued in block' do
worker.perform_async
expect { }.not_to enqueue_sidekiq_job(worker)
end

it 'counts jobs enqueued in block' do
worker.perform_async
expect { worker.perform_async }.to enqueue_sidekiq_job(worker)
end

it 'fails when too many jobs enqueued' do
expect {
expect {
worker.perform_async
worker.perform_async
}.to enqueue_sidekiq_job(worker)
}.to raise_error(/expected to enqueue/)
end

it 'fails when negated and several jobs enqueued' do
expect {
expect {
worker.perform_async
worker.perform_async
}.not_to enqueue_sidekiq_job(worker)
}.to raise_error(/expected not to enqueue/)
end

it 'passes with multiple jobs' do
expect {
another_worker.perform_async
worker.perform_async
}
.to enqueue_sidekiq_job(worker)
.and enqueue_sidekiq_job(another_worker)
end

context 'when enqueued with perform_at' do
it 'passes' do
future = 1.minute.from_now
expect { worker.perform_at(future) }
.to enqueue_sidekiq_job(worker).at(future)
end

it 'fails when timestamps do not match' do
future = 1.minute.from_now
expect {
expect { worker.perform_at(future) }
.to enqueue_sidekiq_job(worker).at(2.minutes.from_now)
}.to raise_error(/expected to enqueue.+at:/m)
end

it 'matches timestamps with nanosecond precision' do
100.times do
future = 1.minute.from_now
future = future.change(nsec: future.nsec.round(-3) + rand(999))
expect { worker.perform_at(future) }
.to enqueue_sidekiq_job(worker).at(future)
end
end

it 'accepts composable matchers' do
future = 1.minute.from_now
slightly_earlier = 58.seconds.from_now
expect { worker.perform_at(slightly_earlier) }
.to enqueue_sidekiq_job(worker).at(a_value_within(5.seconds).of(future))
end

it 'fails when the job was enuqued for now' do
expect {
expect { worker.perform_async }
.to enqueue_sidekiq_job(worker).at(1.minute.from_now)
}.to raise_error(/expected to enqueue.+at:/m)
end
end

context 'when enqueued with perform_in' do
it 'passes' do
interval = 1.minute
expect { worker.perform_in(interval) }
.to enqueue_sidekiq_job(worker).in(interval)
end

it 'fails when timestamps do not match' do
interval = 1.minute
expect {
expect { worker.perform_in(interval) }
.to enqueue_sidekiq_job(worker).in(2.minutes)
}.to raise_error(/expected to enqueue.+in:/m)
end

it 'fails when the job was enuqued for now' do
expect {
expect { worker.perform_async }
.to enqueue_sidekiq_job(worker).in(1.minute)
}.to raise_error(/expected to enqueue.+in:/m)
end
end

it 'matches when not specified at and scheduled for the future' do
expect { worker.perform_in(1.day) }
.to enqueue_sidekiq_job(worker)
expect { worker.perform_at(1.day.from_now) }
.to enqueue_sidekiq_job(worker)
end

context 'with arguments' do
it 'fails with kwargs' do
expect {
expect { worker.perform_async }
.to enqueue_sidekiq_job(worker).with(42, name: 'David')
}.to raise_error(/keyword arguments serialization is not supported by Sidekiq/)
end

it 'passes with provided arguments' do
expect { worker.perform_async(42, 'David') }
.to enqueue_sidekiq_job(worker).with(42, 'David')
end

it 'supports provided argument matchers' do
expect { worker.perform_async(42, 'David') }
.to enqueue_sidekiq_job(worker).with(be > 41, a_string_including('Dav'))
end

it 'passes when negated and arguments do not match' do
expect { worker.perform_async(42, 'David') }
.not_to enqueue_sidekiq_job(worker).with(11, 'Phil')
end

it 'fails when arguments do not match' do
expect {
expect { worker.perform_async(42, 'David') }
.to enqueue_sidekiq_job(worker).with(11, 'Phil')
}.to raise_error(/expected to enqueue.+arguments:/m)
end
end
end

0 comments on commit c151095

Please sign in to comment.