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?