From 56f63872f14d49566066a5da4e33b19486d3f134 Mon Sep 17 00:00:00 2001 From: Isaac Seymour Date: Tue, 31 Jan 2017 22:31:03 +0000 Subject: [PATCH] ActiveJob performed matchers * `have_been_performed` is like `have_been_enqueued`, but for performed jobs * `have_performed_job`/`perform_job` is like `have_enqueued_job`/`enqueue_job`, but for performed jobs --- Changelog.md | 2 + features/job_specs/job_spec.feature | 8 +- .../have_been_performed_matcher.feature | 77 ++++++ .../have_performed_job_matcher.feature | 97 +++++++ lib/rspec/rails/matchers/active_job.rb | 117 +++++++- spec/rspec/rails/matchers/active_job_spec.rb | 258 ++++++++++++++++++ 6 files changed, 553 insertions(+), 6 deletions(-) create mode 100644 features/matchers/have_been_performed_matcher.feature create mode 100644 features/matchers/have_performed_job_matcher.feature diff --git a/Changelog.md b/Changelog.md index ecfde34a1b..e48c945e40 100644 --- a/Changelog.md +++ b/Changelog.md @@ -16,6 +16,8 @@ Enhancements: (David Revelo, #2134) * Add argument matcher support to `have_enqueued_*` matchers. (Phil Pirozhkov, #2206) * Switch generated templates to use ruby 1.9 hash keys. (Tanbir Hasan, #2224) +* Add `have_been_performed`/`have_performed_job`/`perform_job` ActiveJob + matchers (Isaac Seymour, #1785) Bug Fixes: diff --git a/features/job_specs/job_spec.feature b/features/job_specs/job_spec.feature index 00850f7fb3..c57726532d 100644 --- a/features/job_specs/job_spec.feature +++ b/features/job_specs/job_spec.feature @@ -15,9 +15,11 @@ Feature: job spec * specify the queue which the job was enqueued to Check the documentation on - [`have_been_enqueued`](matchers/have-been-enqueued-matcher) and - [`have_enqueued_job`](matchers/have-enqueued-job-matcher) for more - information. + [`have_been_enqueued`](matchers/have-been-enqueued-matcher), + [`have_enqueued_job`](matchers/have-enqueued-job-matcher), + [`have_been_performed`](matchers/have-been-performed-matcher), and + [`have_performed_job`](matchers/have-performed-job-matcher) + for more information. Background: Given active job is available diff --git a/features/matchers/have_been_performed_matcher.feature b/features/matchers/have_been_performed_matcher.feature new file mode 100644 index 0000000000..813e65e5da --- /dev/null +++ b/features/matchers/have_been_performed_matcher.feature @@ -0,0 +1,77 @@ +Feature: have_been_performed matcher + + The `have_been_performed` matcher is used to check if given ActiveJob job was performed. + + Background: + Given active job is available + + Scenario: Checking job class name + Given a file named "spec/jobs/upload_backups_job_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe UploadBackupsJob do + it "matches with performed job" do + ActiveJob::Base.queue_adapter = :test + ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + UploadBackupsJob.perform_later + expect(UploadBackupsJob).to have_been_performed + end + end + """ + When I run `rspec spec/jobs/upload_backups_job_spec.rb` + Then the examples should all pass + + Scenario: Checking passed arguments to job + Given a file named "spec/jobs/upload_backups_job_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe UploadBackupsJob do + it "matches with performed job" do + ActiveJob::Base.queue_adapter = :test + ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + UploadBackupsJob.perform_later("users-backup.txt", "products-backup.txt") + expect(UploadBackupsJob).to( + have_been_performed.with("users-backup.txt", "products-backup.txt") + ) + end + end + """ + When I run `rspec spec/jobs/upload_backups_job_spec.rb` + Then the examples should all pass + + Scenario: Checking job performed time + Given a file named "spec/jobs/upload_backups_job_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe UploadBackupsJob do + it "matches with performed job" do + ActiveJob::Base.queue_adapter = :test + ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true + UploadBackupsJob.set(:wait_until => Date.tomorrow.noon).perform_later + expect(UploadBackupsJob).to have_been_performed.at(Date.tomorrow.noon) + end + end + """ + When I run `rspec spec/jobs/upload_backups_job_spec.rb` + Then the examples should all pass + + Scenario: Checking job queue name + Given a file named "spec/jobs/upload_backups_job_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe UploadBackupsJob do + it "matches with performed job" do + ActiveJob::Base.queue_adapter = :test + ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + UploadBackupsJob.perform_later + expect(UploadBackupsJob).to have_been_performed.on_queue("default") + end + end + """ + When I run `rspec spec/jobs/upload_backups_job_spec.rb` + Then the examples should all pass diff --git a/features/matchers/have_performed_job_matcher.feature b/features/matchers/have_performed_job_matcher.feature new file mode 100644 index 0000000000..cb442640d0 --- /dev/null +++ b/features/matchers/have_performed_job_matcher.feature @@ -0,0 +1,97 @@ +Feature: have_performed_job matcher + + The `have_performed_job` (also aliased as `perform_job`) matcher is used to check if given ActiveJob job was performed. + + Background: + Given active job is available + + Scenario: Checking job class name + Given a file named "spec/jobs/upload_backups_job_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe UploadBackupsJob do + it "matches with performed job" do + ActiveJob::Base.queue_adapter = :test + ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + expect { + UploadBackupsJob.perform_later + }.to have_performed_job(UploadBackupsJob) + end + end + """ + When I run `rspec spec/jobs/upload_backups_job_spec.rb` + Then the examples should all pass + + Scenario: Checking passed arguments to job + Given a file named "spec/jobs/upload_backups_job_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe UploadBackupsJob do + it "matches with performed job" do + ActiveJob::Base.queue_adapter = :test + ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + expect { + UploadBackupsJob.perform_later("users-backup.txt", "products-backup.txt") + }.to have_performed_job.with("users-backup.txt", "products-backup.txt") + end + end + """ + When I run `rspec spec/jobs/upload_backups_job_spec.rb` + Then the examples should all pass + + Scenario: Checking job performed time + Given a file named "spec/jobs/upload_backups_job_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe UploadBackupsJob do + it "matches with performed job" do + ActiveJob::Base.queue_adapter = :test + ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true + expect { + UploadBackupsJob.set(:wait_until => Date.tomorrow.noon).perform_later + }.to have_performed_job.at(Date.tomorrow.noon) + end + end + """ + When I run `rspec spec/jobs/upload_backups_job_spec.rb` + Then the examples should all pass + + Scenario: Checking job queue name + Given a file named "spec/jobs/upload_backups_job_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe UploadBackupsJob do + it "matches with performed job" do + ActiveJob::Base.queue_adapter = :test + ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + expect { + UploadBackupsJob.perform_later + }.to have_performed_job.on_queue("default") + end + end + """ + When I run `rspec spec/jobs/upload_backups_job_spec.rb` + Then the examples should all pass + + Scenario: Using alias method + Given a file named "spec/jobs/upload_backups_job_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe UploadBackupsJob do + it "matches with performed job" do + ActiveJob::Base.queue_adapter = :test + ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + expect { + UploadBackupsJob.perform_later + }.to perform_job(UploadBackupsJob) + end + end + """ + When I run `rspec spec/jobs/upload_backups_job_spec.rb` + Then the examples should all pass diff --git a/lib/rspec/rails/matchers/active_job.rb b/lib/rspec/rails/matchers/active_job.rb index e49b96b8f1..707758a144 100644 --- a/lib/rspec/rails/matchers/active_job.rb +++ b/lib/rspec/rails/matchers/active_job.rb @@ -67,7 +67,7 @@ def thrice end def failure_message - "expected to enqueue #{base_message}".tap do |msg| + "expected to #{self.class::FAILURE_MESSAGE_EXPECTATION_ACTION} #{base_message}".tap do |msg| if @unmatching_jobs.any? msg << "\nQueued jobs:" @unmatching_jobs.each do |job| @@ -78,7 +78,7 @@ def failure_message end def failure_message_when_negated - "expected not to enqueue #{base_message}" + "expected not to #{self.class::FAILURE_MESSAGE_EXPECTATION_ACTION} #{base_message}" end def message_expectation_modifier @@ -119,7 +119,7 @@ def base_message msg << " with #{@args}," if @args.any? msg << " on queue #{@queue}," if @queue msg << " at #{@at.inspect}," if @at - msg << " but enqueued #{@matching_jobs_count}" + msg << " but #{self.class::MESSAGE_EXPECTATION_ACTION} #{@matching_jobs_count}" end end @@ -193,6 +193,9 @@ def queue_adapter # @private class HaveEnqueuedJob < Base + FAILURE_MESSAGE_EXPECTATION_ACTION = 'enqueue'.freeze + MESSAGE_EXPECTATION_ACTION = 'enqueued'.freeze + def initialize(job) super() @job = job @@ -217,6 +220,9 @@ def does_not_match?(proc) # @private class HaveBeenEnqueued < Base + FAILURE_MESSAGE_EXPECTATION_ACTION = 'enqueue'.freeze + MESSAGE_EXPECTATION_ACTION = 'enqueued'.freeze + def matches?(job) @job = job check(queue_adapter.enqueued_jobs) @@ -228,6 +234,38 @@ def does_not_match?(proc) !matches?(proc) end end + + # @private + class HavePerformedJob < Base + FAILURE_MESSAGE_EXPECTATION_ACTION = 'perform'.freeze + MESSAGE_EXPECTATION_ACTION = 'performed'.freeze + + def initialize(job) + super() + @job = job + end + + def matches?(proc) + raise ArgumentError, "have_performed_job only supports block expectations" unless Proc === proc + + original_performed_jobs_count = queue_adapter.performed_jobs.count + proc.call + in_block_jobs = queue_adapter.performed_jobs.drop(original_performed_jobs_count) + + check(in_block_jobs) + end + end + + # @private + class HaveBeenPerformed < Base + FAILURE_MESSAGE_EXPECTATION_ACTION = 'perform'.freeze + MESSAGE_EXPECTATION_ACTION = 'performed'.freeze + + def matches?(job) + @job = job + check(queue_adapter.performed_jobs) + end + end end # @api public @@ -315,6 +353,79 @@ def have_been_enqueued ActiveJob::HaveBeenEnqueued.new end + # @api public + # Passes if a job has been performed inside block. May chain at_least, at_most or exactly to specify a number of times. + # + # @example + # expect { + # perform_jobs { HeavyLiftingJob.perform_later } + # }.to have_performed_job + # + # expect { + # perform_jobs { + # HelloJob.perform_later + # HeavyLiftingJob.perform_later + # } + # }.to have_performed_job(HelloJob).exactly(:once) + # + # expect { + # perform_jobs { 3.times { HelloJob.perform_later } } + # }.to have_performed_job(HelloJob).at_least(2).times + # + # expect { + # perform_jobs { HelloJob.perform_later } + # }.to have_performed_job(HelloJob).at_most(:twice) + # + # expect { + # perform_jobs { + # HelloJob.perform_later + # HeavyLiftingJob.perform_later + # } + # }.to have_performed_job(HelloJob).and have_performed_job(HeavyLiftingJob) + # + # expect { + # perform_jobs { + # HelloJob.set(wait_until: Date.tomorrow.noon, queue: "low").perform_later(42) + # } + # }.to have_performed_job.with(42).on_queue("low").at(Date.tomorrow.noon) + def have_performed_job(job = nil) + check_active_job_adapter + ActiveJob::HavePerformedJob.new(job) + end + alias_method :perform_job, :have_performed_job + + # @api public + # Passes if a job has been performed. May chain at_least, at_most or exactly to specify a number of times. + # + # @example + # before do + # ActiveJob::Base.queue_adapter.performed_jobs.clear + # ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + # ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true + # end + # + # HeavyLiftingJob.perform_later + # expect(HeavyLiftingJob).to have_been_performed + # + # HelloJob.perform_later + # HeavyLiftingJob.perform_later + # expect(HeavyLiftingJob).to have_been_performed.exactly(:once) + # + # 3.times { HelloJob.perform_later } + # expect(HelloJob).to have_been_performed.at_least(2).times + # + # HelloJob.perform_later + # HeavyLiftingJob.perform_later + # expect(HelloJob).to have_been_performed + # expect(HeavyLiftingJob).to have_been_performed + # + # HelloJob.set(wait_until: Date.tomorrow.noon, queue: "low").perform_later(42) + # expect(HelloJob).to have_been_performed.with(42).on_queue("low").at(Date.tomorrow.noon) + def have_been_performed + check_active_job_adapter + ActiveJob::HaveBeenPerformed.new + end + private # @private diff --git a/spec/rspec/rails/matchers/active_job_spec.rb b/spec/rspec/rails/matchers/active_job_spec.rb index 7c6f6aac47..5971200c7b 100644 --- a/spec/rspec/rails/matchers/active_job_spec.rb +++ b/spec/rspec/rails/matchers/active_job_spec.rb @@ -405,4 +405,262 @@ def self.name; "LoggingJob"; end .to have_been_enqueued.at(a_value_within(5.seconds).of(future)) end end + + describe "have_performed_job" do + before do + ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true + + # stub_const is used so `job_data["job_class"].constantize` works + stub_const('HeavyLiftingJob', heavy_lifting_job) + stub_const('HelloJob', hello_job) + stub_const('LoggingJob', logging_job) + end + + it "raises ArgumentError when no Proc passed to expect" do + expect { + expect(heavy_lifting_job.perform_later).to have_performed_job + }.to raise_error(ArgumentError) + end + + it "passes with default jobs count (exactly one)" do + expect { + heavy_lifting_job.perform_later + }.to have_performed_job + end + + it "counts only jobs performed in block" do + heavy_lifting_job.perform_later + expect { + heavy_lifting_job.perform_later + }.to have_performed_job.exactly(1) + end + + it "passes when negated" do + expect { }.not_to have_performed_job + end + + it "fails when job is not performed" do + expect { + expect { }.to have_performed_job + }.to raise_error(/expected to perform exactly 1 jobs, but performed 0/) + end + + it "fails when too many jobs performed" do + expect { + expect { + heavy_lifting_job.perform_later + heavy_lifting_job.perform_later + }.to have_performed_job.exactly(1) + }.to raise_error(/expected to perform exactly 1 jobs, but performed 2/) + end + + it "reports correct number in fail error message" do + heavy_lifting_job.perform_later + expect { + expect { }.to have_performed_job.exactly(1) + }.to raise_error(/expected to perform exactly 1 jobs, but performed 0/) + end + + it "fails when negated and job is performed" do + expect { + expect { heavy_lifting_job.perform_later }.not_to have_performed_job + }.to raise_error(/expected not to perform exactly 1 jobs, but performed 1/) + end + + it "passes with job name" do + expect { + hello_job.perform_later + heavy_lifting_job.perform_later + }.to have_performed_job(hello_job).exactly(1).times + end + + it "passes with multiple jobs" do + expect { + hello_job.perform_later + logging_job.perform_later + heavy_lifting_job.perform_later + }.to have_performed_job(hello_job).and have_performed_job(logging_job) + end + + it "passes with :once count" do + expect { + hello_job.perform_later + }.to have_performed_job.exactly(:once) + end + + it "passes with :twice count" do + expect { + hello_job.perform_later + hello_job.perform_later + }.to have_performed_job.exactly(:twice) + end + + it "passes with :thrice count" do + expect { + hello_job.perform_later + hello_job.perform_later + hello_job.perform_later + }.to have_performed_job.exactly(:thrice) + end + + it "passes with at_least count when performed jobs are over limit" do + expect { + hello_job.perform_later + hello_job.perform_later + }.to have_performed_job.at_least(:once) + end + + it "passes with at_most count when performed jobs are under limit" do + expect { + hello_job.perform_later + }.to have_performed_job.at_most(:once) + end + + it "generates failure message with at least hint" do + expect { + expect { }.to have_performed_job.at_least(:once) + }.to raise_error(/expected to perform at least 1 jobs, but performed 0/) + end + + it "generates failure message with at most hint" do + expect { + expect { + hello_job.perform_later + hello_job.perform_later + }.to have_performed_job.at_most(:once) + }.to raise_error(/expected to perform at most 1 jobs, but performed 2/) + end + + it "passes with provided queue name" do + expect { + hello_job.set(:queue => "low").perform_later + }.to have_performed_job.on_queue("low") + end + + it "passes with provided at date" do + date = Date.tomorrow.noon + expect { + hello_job.set(:wait_until => date).perform_later + }.to have_performed_job.at(date) + end + + it "passes with provided arguments" do + expect { + hello_job.perform_later(42, "David") + }.to have_performed_job.with(42, "David") + end + + it "passes with provided arguments containing global id object" do + global_id_object = GlobalIdModel.new("42") + + expect { + hello_job.perform_later(global_id_object) + }.to have_performed_job.with(global_id_object) + end + + it "passes with provided argument matchers" do + expect { + hello_job.perform_later(42, "David") + }.to have_performed_job.with(42, "David") + end + + it "generates failure message with all provided options" do + date = Date.tomorrow.noon + message = "expected to perform exactly 2 jobs, with [42], on queue low, at #{date}, but performed 0" + \ + "\nQueued jobs:" + \ + "\n HelloJob job with [1], on queue default" + + expect { + expect { + hello_job.perform_later(1) + }.to have_performed_job(hello_job).with(42).on_queue("low").at(date).exactly(2).times + }.to raise_error(message) + end + + it "throws descriptive error when no test adapter set" do + queue_adapter = ActiveJob::Base.queue_adapter + ActiveJob::Base.queue_adapter = :inline + + expect { + expect { heavy_lifting_job.perform_later }.to have_performed_job + }.to raise_error("To use ActiveJob matchers set `ActiveJob::Base.queue_adapter = :test`") + + ActiveJob::Base.queue_adapter = queue_adapter + end + + it "fails with with block with incorrect data" do + expect { + expect { + hello_job.perform_later("asdf") + }.to have_performed_job(hello_job).with { |arg| + expect(arg).to eq("zxcv") + } + }.to raise_error { |e| + expect(e.message).to match(/expected: "zxcv"/) + expect(e.message).to match(/got: "asdf"/) + } + end + + it "passes multiple arguments to with block" do + expect { + hello_job.perform_later("asdf", "zxcv") + }.to have_performed_job(hello_job).with { |first_arg, second_arg| + expect(first_arg).to eq("asdf") + expect(second_arg).to eq("zxcv") + } + end + + it "passess deserialized arguments to with block" do + global_id_object = GlobalIdModel.new("42") + + expect { + hello_job.perform_later(global_id_object, :symbolized_key => "asdf") + }.to have_performed_job(hello_job).with { |first_arg, second_arg| + expect(first_arg).to eq(global_id_object) + expect(second_arg).to eq({:symbolized_key => "asdf"}) + } + end + + it "only calls with block if other conditions are met" do + noon = Date.tomorrow.noon + midnight = Date.tomorrow.midnight + expect { + hello_job.set(:wait_until => noon).perform_later("asdf") + hello_job.set(:wait_until => midnight).perform_later("zxcv") + }.to have_performed_job(hello_job).at(noon).with { |arg| + expect(arg).to eq("asdf") + } + end + end + + describe "have_been_performed" do + before do + ActiveJob::Base.queue_adapter.performed_jobs.clear + ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true + stub_const('HeavyLiftingJob', heavy_lifting_job) + end + + it "passes with default jobs count (exactly one)" do + heavy_lifting_job.perform_later + expect(heavy_lifting_job).to have_been_performed + end + + it "counts all performed jobs" do + heavy_lifting_job.perform_later + heavy_lifting_job.perform_later + expect(heavy_lifting_job).to have_been_performed.exactly(2) + end + + it "passes when negated" do + expect(heavy_lifting_job).not_to have_been_performed + end + + it "fails when job is not performed" do + expect { + expect(heavy_lifting_job).to have_been_performed + }.to raise_error(/expected to perform exactly 1 jobs, but performed 0/) + end + end end