Search code examples
rubyrspecfactory-botactivesupportrspec-mocks

How to spy just one call of ActiveSupport::Notifications #instrument, not all of them


I'm making a Rspec test that checks if ActiveSupport::Notification.instrument was called with some parameters.

The thing is that in order to make this test a need FactoryBot to build some objects, but when I try to spy on ActiveSupport::Notification.instrument I always get an error:

ActiveSupport::Notifications received :instrument with unexpected arguments
         expected: (:asd)
              got: ("factory_bot.run_factory", {:factory=>#<FactoryBot::Factory:0x005569b6d30, @al... nil, dispatch: nil, distribution_state: 2, main_category_id: nil>}, :strategy=>:build, :traits=>[]})

It seems that FactoryBot calls activesupport so when I mock it for my test purpose I end up mocking it too far...

code example:

class:

class SomeObject
    def initialize(something)
        #some code
    end

    def my_method
        ActiveSupport::Notifications.instrument :asd
    end
end

spec:

describe "#my_method" do
    let(:some_object) { build :some_object }
    before do
      allow(ActiveSupport::Notifications).to receive(:instrument).with :asd
    end

    it "calls notifier" do
      described_class.new(some_object).my_method

      expect(ActiveSupport::Notifications).to have_received(:instrument).with :asd
    end
  end

How can I just mock my call and not FactoryBot's .

I only manage that with one more allow before the one that mocks :asd:

 allow(ActiveSupport::Notifications).to receive(:instrument).and_call_original

Is there another(better) way?


Solution

  • I tend to avoid mocking in general.

    I had a similar problem and here's how I achieved it:

      describe "#my_method" do
        let(:some_object) { build :some_object }
    
        before { record_events }
    
        it "calls notifier" do
          described_class.new(some_object).my_method
    
          # Make sure your event was triggered
          expect(events.map(&:name)).to include('asd')
    
          # Check number of events
          expect(events).to be_one
    
          # Check contents of event payload                  
          expect(events.first.payload).to eq({ 'extra' => 'context' })
    
          # Even check the duration of an event
          expect(events.first.duration).to be < 3
        end
    
        private
    
        attr_reader :events
    
        def record_events
          @events = []
          ActiveSupport::Notifications.subscribe(:asd) do |*args| #
            @events << ActiveSupport::Notifications::Event.new(*args)
          end
        end
      end
    

    Advantages over mocking

    • No more weird side effects
    • Using ActiveSupport::Notifications as intended
    • ActiveSupport::Notifications::Event wrapper gives you nice extras like #duration
    • Easily check for other events being triggered
    • The ability to only look at events that match a name - use ActiveSupport::Notifications.subscribe(/asd/) to do partial matches on event names
    • Better readability - checking events array is more readable

    Disadvantages over mocking

    • Way more code
    • Mutates the @events array
    • Possible dependencies between tests if you don't clear @events on teardown