0

I've been dancing around this for a little bit, and can't seem to figure it out.

For reference: Testing after_commit with RSpec and mocking http://www.chrisrolle.com/en/blog/activerecord-callback-tests-with-rspec

My code:

# model
class DataSource < ApplicationRecord
  after_commit :subscribe, on: %i[create update], if: :url_source?
...
end

# spec file
require 'rails_helper'
require 'sidekiq/testing' #include in your Rspec file
Sidekiq::Testing.fake! #include in your RSpec file

RSpec.describe DataSource, type: :model do

  describe 'When creating, updating or destroying feed sources' do
    let(:create_data_source) { FactoryBot.build(:data_source) }
    it 'should call subscribe when created' do
      create_data_source.run_callbacks(:create)
      expect(create_data_source).to receive(:subscribe)
    end

FactoryBot.build creates a .new instance of the object in question. So, like Christian Rolle's post, I've got a .new thing that isn't yet saved. I'm assuming that run_callbacks(:create) is actually doing the create.

Looking in the test log, that appears to be the case:

 DataSource Create (0.2ms)  INSERT INTO "data_sources" ("name", "slug", "url", "data", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id"  [["name", "Andreas Johnston"], ["slug", "andreas-johnston"], ["url", "http://kunze.biz/titus"], ["data", "{\"feed_type\":\"rss\"}"], ["created_at", "2021-12-02 19:54:32.967931"], ["updated_at", "2021-12-02 19:54:32.967931"]]

If I insert a binding.pry into the subscribe method on the DataSource model and execute the test, I do end up inside the subscribe method, so that is also being called.

However, rspec reports the following error:

       (#<DataSource id: nil, name: "Gov. Rachelle Ernser", slug: nil, url: "http://goldner.info/shakia", data: {"feed_type"=>"rss"}, created_at: nil, updated_at: nil, abstracted_source: false, status: nil>).subscribe(*(any args))
           expected: 1 time with any arguments
           received: 0 times with any arguments

If I change the test to the following:

    let(:create_data_source) { FactoryBot.create(:data_source) }
    it 'should call subscribe when created' do
      expect(create_data_source).to receive(:subscribe)
    end

I get the same error, except it shows that the object was already created at the time rspec was looking (created_at has a value in the output):

     Failure/Error: expect(create_data_source).to receive(:subscribe)
     
       (#<DataSource id: 3, name: "Jesusita Kuhic", slug: "jesusita-kuhic", url: "http://nitzsche-gutkowski.io/cory.prosacco", data: {"feed_type"=>"rss"}, created_at: "2021-12-02 19:57:08", updated_at: "2021-12-02 19:57:08", abstracted_source: false, status: nil>).subscribe(*(any args))
           expected: 1 time with any arguments
           received: 0 times with any arguments

It was recommended in another thread somewhere to use shoulda-callback-matchers. Unfortunately, there is a bug with it and Rails 5.2 and Rails 6 that breaks my particular case: https://github.com/jdliss/shoulda-callback-matchers/issues/26

The project has not been updated since 2016, and a fix has been outstanding for some time but it has not been accepted.

I'm not really sure how else to test that the subscribe method is being called when the object is created. Update seems to work just fine:

    it 'should call subscribe when updated' do
      expect(data_source).to receive(:subscribe)
      data_source.save
    end

Calling .save on a previously un-saved object also seems to work:

    it 'should call subscribe when created' do
      ds = FactoryBot.build(:data_source)
      expect(ds).to receive(:subscribe)
      ds.save
    end

But I'm not sure if it's acceptable/safe/good practice to test create via new+save.

Erik Jacobs
  • 841
  • 3
  • 7
  • 19
  • I think it's doing what you think. The expectation needs to come before the message is sent. – Jake Worth Dec 02 '21 at 20:43
  • I actually was running rspec wrong. About to update the thread. Changing the order doesn't fix anything. – Erik Jacobs Dec 02 '21 at 20:55
  • Is `url_source?` true for that data source? That would be part of the setup as well. – Jake Worth Dec 02 '21 at 21:35
  • Have you considered not using a callback? Instead create a service object that creates the object and subscribes. That gives you explicit control of when it happens and is very straight forward to test. – max Dec 03 '21 at 05:53
  • @JakeWorth the DataSource factory creates a source where `url_source?` is true. If it wasn't a `url_source?` then neither the save (`:update`) nor `:create` would call subscribe. – Erik Jacobs Dec 03 '21 at 15:30
  • @max this app makes extensive use of callbacks and they are a standard feature of rails. I find it weird that they are so hard to test. – Erik Jacobs Dec 03 '21 at 15:31
  • @ErikJacobs I think this answer makes an argument worth considering. https://stackoverflow.com/a/41604232/2112512 – Jake Worth Dec 03 '21 at 16:55
  • @ErikJacobs well that's the core problem with callbacks - they can't be decoupled from the other method which they are a callback on and you don't have much control over when and where the callback is fired. In this case it doesn't work since you're setting the expectation after you create the record. Just because it's a "standard feature" does not mean that it a huge tradeoff from just a little convenience. https://dev.to/mickeytgl/the-good-and-bad-of-activerecord-callbacks-p4a – max Dec 03 '21 at 19:43
  • It's perfectly acceptable to test `create` via `save`. I would go that way. – ijmorales Dec 04 '21 at 18:25

1 Answers1

0

I was having a similar issue and I ended up using a stubbed record and mocking its state to catch the callbacks, like this:

describe 'When creating, updating or destroying feed sources' do
  let(:data_source) { FactoryBot.build_stubbed(:data_source) }

  context 'on create' do
    before do
      # Make the commit callback identify a new record (on: :create)
      allow(data_source).to receive(:persisted?).and_return(true)
      data_source.instance_variable_set(:@_new_record_before_last_commit, true)
    end

    it 'should call subscribe when created' do
        expect(data_source).to receive(:subscribe)

        data_source.run_callbacks(:commit)
      end
    end
  end
end

For update you can use

  before do
    # Make the commit callback identify a update (on: :update)
    allow(data_source).to receive(:persisted?).and_return(true)
    data_source.instance_variable_set(:@_trigger_update_callback, true)
  end

I had to dig in the Rails code to understand how I could fake the stubbed record states while running the commit callback.

I hope it helps someone!

Victor BV
  • 1,051
  • 13
  • 9