palkan / active_delivery

Ruby framework for keeping all types of notifications (mailers, push notifications, whatever) in one place

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Spec matcher for callbacks

benebrice opened this issue · comments

Is your feature request related to a problem? Please describe.

With inheritance, if becomes more and more complicated to test callbacks correctly. A dedicated matcher could help a lot with that.

Describe the solution you'd like

describe '#initialize' do
  let(:service) { MyDelivery.with(foo: :bar) }
 
  it 'has `baz` callback' do
    expect(service).to have_active_callback(:baz)
                                       .with_action(:before)
                                       .on(:mailer)
                                       .only_for(:foo_bar)
                                       .and_return(true)
  end
end

Describe alternatives you've considered

Unless, you need to test every single clauses manually for each method called.

Personally, I'm against such testing techniques and don't want to promote it. It brings little value and has nothing with the code under test behaviour (it's like testing the syntax of the source code).

If you want to test shared callbacks (inherited or extracted into modules/concerns), you can test them in isolation (e.g., using anonymous classes, like RSpec's anonymous controller). So, you don't need to test the same callback in all children classes. Another option is to use shared examples (though, it seems to me like an overhead in this case).

I get your point.

I personnel found it easier to "freeze" the code and be 100% sure it has not been updated by mistake.

For example, if I have

before_notify :do_something, on: :mailer, only: :my_method

And update it to

before_notify :do_something, only: :my_method

Callback is called but the behaviour might change completely because of the usage of another line you did not want. More you have lines, more it becomes necessary. I'm currently having 3 lines (4 sooner has excepted) and I had to split Delivery class to isolate method/lines. I becomes messy faster than imagine. That's because of this spit that I thought about making the test of line easier.

Also, it allows you to check the response of the callback and check if you're really returning what you think you are. It simplifies debug + code comprehension for other developers + reduce test length drastically.

Code is done on my side. Fews tests still need to be added. It's up to you if you want me to share it here or not. 🙂

Ok, I see.

Code is done on my side

If so, let's proceed in the PR.

I would suggest some changes to the API:

  • Let's use use_{before,after,around}_notify naming similar to shoulda-matchers use_x_action—it's better to use potentially familiar interface

  • We can have a mix of configuration/behaviour check by avoiding checking for exact notifications (only:, except:). For example:

let(:params) { {foo: bar} }
let(:delivery) { MyDelivery.with(**params) }

it "invokes before callback do_smth only for some_action" do
  expect { delivery.notify(:some_action) }.to use_before_notify(:do_smth).on(:mailer)
  expect { delivery.notify(:another_action) }.not_to use_before_notify(:do_smth)
end

context "when context is different" do
  let(:params) { {foo: nil} }

  it "doesn't invoke do_smth callback" do
    expect { delivery.notify(:some_action) }.not_to use_before_notify(:do_smth)
  end
end

WDYT?

It sounds good.

But if we use expect{ } it becomes harder to have access to callback before calling the block from what I know.

def matches?(our_instance_or_block)
  # to checks here
  our_instance_or_block.call if our_instance_or_block.is_a?(Proc)
  # maybe something here too
end

Do you have an idea to access to instance variables of delivery ?

Only solution I see is to pass delivery as parameter of use_before_notify but it does not look very well.

Unless, we won't be able to check the response of the callback called (we only have access to the Delivery class, not the instance).

Then, I would recommend something hybrid between our 2 versions:

# calls implicitly delivery.notify
expect(delivery).to use_before_notify(:do_smth).on(:mailer)

# calls implicitly delivery.notify(:some_action)
expect(delivery).to use_before_notify(:do_smth).on(:mailer).only_for(:some_action)

Also, one important point to note is that ActiveSupport::Callbacks::Callback handles conditions into 2 arrays @if and @unless. only|except are converted to Proc and added to those arrays. It means

before_notify :do_something, on: :mailer, only: %i[my_method my_other_method], if: -> { is_it_ok? }

Creates 3 elements on the array @if where each is a Proc. It makes it difficult to test because only: %i[my_method my_other_method] can be true but not if: -> { is_it_ok? }.

Since it is impossible to know the content of a proc, the only way I found to make a different from one to another is linked to the source_location of the proc.

  • .*delivery.rb -> only|except
  • .*callbacks.rb -> if|unless

Creates 3 elements on the array @if where each is a Proc. It makes it difficult to test because only: %i[my_method my_other_method] can be true but not if: -> { is_it_ok? }.

Well, that only adds to my argument that testing callbacks this way (by looking into internals or stubbing extensively) isn't a good idea 🤷‍♂️


I found an example that demonstrates how to test callbacks behaviour in isolation. Maybe, that would be useful:

class BaseDelivery < ActiveDelivery::Base
  self.abstract_class = true

  before_notify :ensure_user_set
  before_notify :ensure_receive_emails, on: :mailer

  private

  def ensure_user_set
    return if user

    raise ArgumentError, "User must be provided"
  end

  def ensure_receive_emails
    user.receive_emails?
  end

  def user
    @user ||= params[:user]
  end
end

describe BaseDelivery do
  before(:all) do
    class ApplicationDelivery::TestMailer < ActionMailer::Base
      def test_me(text)
        user = params[:user]
        mail(
          to: user.email,
          body: text
        )
      end
    end

    class ApplicationDelivery::TestDelivery < ConnectBy::BaseDelivery
    end
  end

  after(:all) do
    %i[TestMailer TestDelivery].each do |const|
      ApplicationDelivery.send(:remove_const, const)
    end
  end

  let(:user) { build_stubbed :user }

  describe ".notify" do
    subject { ApplicationDelivery::TestDelivery.with(user: user) }

    let(:params_mailer) { double("ActionMailer::Parameterized::Mailer") }

    before do
      allow(ConnectBy::ApplicationDelivery::TestMailer)
        .to receive(:with).with(user: user) { params_mailer }
    end

    it "delivers email if receive_emails is true" do
      user.receive_emails = true

      mail = double("Mail")

      expect(params_mailer).to receive(:test_me).with("hello!").and_return(mail)
      expect(mail).to receive(:deliver_later)

      subject.notify(:test_me, "hello!")
    end

    it "doesn't deliver email when receive_emails is false" do
      user.receive_emails = false

      expect(params_mailer).not_to receive(:test_me)

      subject.notify(:test_me, "test")
    end

    it "raises ArgumentError when user is not provided" do
      expect { ApplicationDelivery::TestDelivery.with(nouser: true).notify(:test_me, "hi!") }
        .to raise_error(ArgumentError, /user must be provided/i)
    end
  end
end

I had something similar before trying to simplify it. It is quite difficult to understand if you did not write the code yourself. I mean, if might become a place where code works but nobody remembers why so "do not touch it". 😁

Actually, I found a way to make it work with a single instance_variable_set for notification_name on the instance. It means the notify method does not have to be called which is good to test a callback with AASM for example. Unless, you're getting error since state has already changed on the first call.

Syntax would be:

let(:service) { MyDelivery.with(some: :thing) }

it 'has `my_before_callback` callback' do
  expect(service).to use_before_notify(:my_before_callback)
                                        .on(:mailer)
                                        .only_for(:specific_method)
                                        .and_return(:foo)
end

Basically, workflow I currently have is a field status that changes before lines are called and after async jobs are created. That way, if something went wrong, it is directly visible via the status.

What do you think?