🫠

A little gotcha in RSpec

2024/03/23に公開

If you've ever worked with RSpec to test ActiveJob jobs, you're probably familiar with testing a job performed with specific arguments.

require "rails_helper"

RSpec.describe UploadBackupsJob do
  it "matches with enqueued job" do
    ActiveJob::Base.queue_adapter = :test
    expect {
      UploadBackupsJob.perform_later("users-backup.txt", "products-backup.txt")
    }.to have_enqueued_job.with("users-backup.txt", "products-backup.txt")
  end
end

Now, if your arguments aren't available before running expect, you'll need to use lazy evaluation with a block.

require "rails_helper"

RSpec.describe UploadBackupsJob do
  it "matches with enqueued job" do
    ActiveJob::Base.queue_adapter = :test
    expect {
      UploadBackupsJob.perform_later('backups.txt', rand(100), 'uninteresting third argument')
    }.to have_enqueued_job.with { |file_name, seed|
      expect(file_name).to eq 'backups.txt'
      expect(seed).to be < 100
    }
  end
end

(Examples are from the official documentation)

Here, you might notice that .with uses curly braces instead of a do ... end block. The {} with expect is easily explained because a chaining method is called after that. But why doesn't .with use do ... end even when the block content is multiline? As a fan of Rubocop, I got used to the default block delimiter style, so I automatically replaced the braces with do ... end without checking if it works.

expect {
  UploadBackupsJob.perform_later('backups.txt', rand(100), 'uninteresting third argument')
}
  .to have_enqueued_job(UploadBackupsJob)
  .with do |file_name, seed|
    expect(file_name).to eq 'backups.txt'
    expect(seed).to be < 100
  end

And... the test passed! Unfortunately, later, I discovered it was a false negative. The block wasn't executed at all!

It took me a while to understand the difference.

Firstly, {} has higher precedence than do ... end. You can check this in the Ruby documentation.

{ ... } blocks have priority below all listed operations, but do ... end blocks have lower priority.

Secondly, the .to method accepts a block and passes it to the matcher's .matches? method.
https://github.com/rspec/rspec-expectations/blob/v3.13.0/lib/rspec/expectations/expectation_target.rb#L63-L66
https://github.com/rspec/rspec-expectations/blob/v3.13.0/lib/rspec/expectations/handler.rb#L47-L58

So it turns out the code was interpreted like this:

expect {
  UploadBackupsJob.perform_later('backups.txt', rand(100), 'uninteresting third argument')
}
  .to(have_enqueued_job(UploadBackupsJob).with) do |file_name, seed|
    expect(file_name).to eq 'backups.txt'
    expect(seed).to be < 100
  end

And the matcher (in this case, RSpec::Rails::Matchers::ActiveJob::HaveEnqueuedJob) simply ignored the block (no yield)
https://github.com/rspec/rspec-rails/blob/main/lib/rspec/rails/matchers/active_job.rb#L230-L238

That's it. I will use {} in this case for now, but I might forget again someday. So, if I can muster the energy, I'll try to write a cop for it.

YAMAP テックブログ

Discussion