Using rspec to test ActionMailer with should_receive - ruby-on-rails

I have an RSpec test like this:
it "should ..." do
# mailer = mock
# mailer.should_receive(:deliver)
Mailer.should_receive(:notification_to_sender)#.and_return(mailer)
visit transactions_path
expect do
page.should_not have_css("table#transactions_list tbody tr")
find('#some_button').click
page.should have_css("table#transactions_list tbody tr", :count => 1)
end.to change{Transaction.count}.by(1)
end
If I remove the commented pieces at the top, the test passes. But with the commented sections in place (how I'd expect to write it) the test fails.
I got the commented pieces off some of googling around the net, but I don't really understand what it's doing or why this fixes it. It seems like there should be a cleaner way to test emails without this.
Can anyone shed some light? Thanks!
I'm using rails 3 and rspec-rails 2.10.1

I think you want an instance of Mailer to receive notification_to_sender not the class. From the Rails API
You never instantiate your mailer class. Rather, your delivery instance methods are automatically wrapped in class methods that start with the word deliver_ followed by the name of the mailer method that you would like to deliver. The signup_notification method defined above is delivered by invoking Notifier.deliver_signup_notification.
Therefore I would use
Mailer.any_instance.should_receive(:notification_to_sender)
Also, if you need to get the last delivered message, use
ActionMailer::Base.deliveries.last
I think that should solve your problem.

You're likely calling Mailer.notification_to_sender.deliver in your controller, or better yet, a background job. I'm guessing notification_to_sender probably takes a parameter as well.
Anyways, when you call the notification_to_sender method on Mailer you're getting back an instance of Mail::Message that has the deliver method on it. If you were simply doing Mailer.notification_to_sender without also calling deliver, you could run what you have there with the comments and all would be fine. I would guess you're also calling deliver though.
In that case your failure message would be something like
NoMethodError:
undefined method `deliver' for nil:NilClass
That is because nil is Ruby's default return value much of the time, which Rails also inherits. Without specifying the mailer = mock and .and_return(mailer) parts, when the controller executes in context of the test then notification_to_sender will return nil and the controller will try to call deliver on that nil object.
The solution you have commented out is to mock out notification_to_sender's return value (normally Mail::Message) and then expect that deliver method to be called on it.

Related

How to best use rspec mocks when using rails mailer parameterization

I'm currently changing our rails mailers to use the newer way of using the mailer that uses parameterization, which brings our code base inline with the rails guide, but more importantly it also allows the parameters to be filtered appropriately in the logs and 3rd party apps like AppSignal.
ie. I'm changing this
UserMailer.new_user_email(user).deliver_later
to
UserMailer.with(user: user).new_user_email.deliver_later
But we have a quite a few specs that use Rspec Mocks to confirm that a mailer was called with the appropriate params. Generally these test that a controller actually asked the mailer to spend the email correctly.
We generally have something like:
expect(UserMailer).to receive(:new_user_email)
.with(user)
.and_return(OpenStruct.new(deliver_later: true))
.once
But now with the parameterization of the mailer, I don't see any easy way to use rspec mocks to verify that the correct mailer method was called with the correct params. Does anyone have any ideas on how best to test this now? Readability of the expectation is probably the biggest factor here, ideally it is one line without multiple lines of mocking setup.
Note: that I don't really want to actually run the mailer, we have mailer unit specs that test the actual mailer is working.
When you have couple of methods which are chained you can use receive_message_chain
But there is one backdraw - it doesn't support the whole fluent interface of counters like once twice
So you have to do one trick here:
# set counter manually
counter = 0
expect(UserMailer).to receive_message_chain(
:with, :new_user_email
).with(user: user).with(no_args).and_return(OpenStruct.new(deliver_later: true)) do
counter += 1
end
# Very important: Here must be call of your method which triggers `UserMailer` mailer. For example
UserNotifier.notify_user(user)
expect(counter).to eq(1)
# class for example
class UserNotifier
def self.notify_user(user)
UserMailer.with(user: user).new_user_email.deliver_later
end
end
So for anyone else that hits this problem in the future. I ended up adding a helper method in the specs/support directory with something like this
def expect_mailer_call(mailer, action, params, delivery_method = :deliver_later)
mailer_double = instance_double(mailer)
message_delivery_double = instance_double(ActionMailer::MessageDelivery)
expect(mailer).to receive(:with).with(params).and_return(mailer_double)
expect(mailer_double).to receive(action).with(no_args).and_return(message_delivery_double)
expect(message_delivery_double).to receive(delivery_method).once
end
Which can then be called in a spec like this
expect_mailer_call(UserMailer, :new_user_email, { to:'email#email.com', name: kind_of(String) })
or for deliver_now
expect_mailer_call(UserMailer, :new_user_email, { to:'email#email.com', name: kind_of(String) }, :deliver_now)
It works good enough for our situation, but you might need to adapt it and add a part for the amount of emails or something if you need to configure the once restriction.

Rspec - Testing an instance of a method

I'm implemented a condition that if a user's email is not confirmed, they do not receive emails. My logic is, if the email isn't verified/ confirmed, return an instance of ActionMailer::Base::NullMail.new.
I wrote this test
if #user.verified?
next
else
ActionMailer::Base::NullMail.new
end
expect(TestMailer.test_mail(expired_account)).to be_an_instance_of(ActionMailer::Base::NullMail)
and the error I'm getting is
expected #<ActionMailer::MessageDelivery(#<ActionMailer::Base::NullMail:0x00007fb0f2f24090>)> to be a kind of ActionMailer::Base::NullMail
The question is, how can I re-write my test so that I can test that if the emails aren't verified they should return an instance of ActionMailer::Base::NullMail.new
The error is telling you what the problem is right? You expect an instance of a mail type object, so change it to this. Also you should be able to use be_a method in place of be_an_instance_of in Rspec see docs
expect(TestMailer.test_mail(expired_account).message)
.to be_an(ActionMailer::Base::NullMail)

Rspec: Test if instance method was called on specific record with ActiveRecord

I'm looking to write a controller spec which tests if a method was called on an instance of a model class for ActiveRecord.
For example, there is a model Post and I want to check if the post with the id 55 had the method foobar called on it.
Some ways that almost work:
expect_any_instance_of(Post).to receive(:foobar)
This almost works but it can not check which post the method was called on
using double
This would normally work but in the controller spec, only ids are passed over so I have no way of inserting the double, short of mocking the response from activerecord find
Does rspec provide any tools to check a method was called on a specific model instance?
It is possible but requires much more mocking, and this means that you're testing the implementation details of a controller which is an antipattern.
Assuming your controller is this:
def action(id)
Post.find(params[:id]).foobar
end
You'd need to mock sth like this (I'll ignore RSpec's good practices like having vars in let's, you can easily extract it later when you have a working example):
mock = instance_double(Post)
expect(Post).to receive(:find).with(55).and_return(mock)
expect(mock).to receive(:foobar)
# trigger the action here
But as the comments already stated, it's probably cleaner to test side effects. If your action deletes a Post, check that the post has been deleted
expect { trigger_action params: {id: 55} }.to change(Post, :count).by(-1)
This makes your code less brittle (you can refactor the internals, and the specs for the controller stay green - meaning your app still works as described in those specs).

Rspec: How to use expect to receive with a resource which does not exist yet?

In an action called via a post request I'm creating a resource call RequestOffer and send an email with ActionMailer using the created resource as a parameter:
#request_offer = RequestOffer.new(request_offer_params)
if #request_offer.save
RequestOfferMailer.email_team(#request_offer).deliver_later
end
When my controller spec, I want to test that my RequestOfferMailer is called using the method email_team with the resource #request_offer as a parameter.
When I want to user expect(XXX).to receive(YYY).with(ZZZ), the only way I found was to declare my expectation before making the POST request. However, ZZZ is created by this POST request, so I have no way to set my expectation before.
# Set expectation first
message_delivery = instance_double(ActionMailer::MessageDelivery)
# ZZZ used in .with() does not exist yet, so it won't work
expect(RequestOfferMailer).to receive(:email_team).with(ZZZ).and_return(message_delivery)
expect(message_delivery).to receive(:deliver_later)
# Make POST request that will create ZZZ
post :create, params
Any idea how to solve this problem?
If this is a functional test then I would isolate the controller test from the DB. You can do this by using instance_doubles and let statements. Here's an example that you may like to extend for your purposes
describe '/request_offers [POST]' do
let(:request_offer) { instance_double(RequestOffer, save: true) }
before do
allow(RequestOffer).to receive(:new).
with(...params...).
and_return(request_offer)
end
it 'should instantiate a RequestOffer with the params' do
expect(RequestOffer).to receive(:new).
with(...params...).
and_return(request_offer)
post '/request_offers', {...}
end
it 'should email the request offer via RequestOfferMailer' do
mailer = instance_double(ActionMailer::MessageDelivery)
expect(RequestOfferMailer).to receive(:email_team).
with(request_offer).and_return(mailer)
post '/request_offers', {...}
end
end
The key to this is using 'let' to declare an instance double of the model that you intend to create. By setting expectations on the class you can inject your instance double into the test and isolate from the DB. Note that the 'allow' call in the before block is there to serve the later specs that set expectations on the mailer object; the 'expect' call in the first test will still be able to make assertions about the call.
Would it be enough to make sure the argument is an instance of RequestOffer? Then you could use the instance_of matcher. For example:
expect(RequestOfferMailer).to receive(:email_team).with(instance_of(RequestOffer)).and_return(message_delivery)
I found this option in the Rspec 3.0 docs: https://relishapp.com/rspec/rspec-mocks/v/3-0/docs/setting-constraints/matching-arguments
The last argument of the with method is a block. You can open up the arguments and do anything you like there.
expect(RequestOfferMailer)
.to receive(:email_team)
.with(instance_of(RequestOffer)) do |request_offer|
expect(request_offer.total).to eq(100) # As one example of what you can to in this block
end.and_return(message_delivery)
You can also set the instance_of matcher to be anything if you're not even sure what object type you're expecting.

How can I abort the delivery of an ActionMailer request?

I'm running a Q&A service. One of the things admins can do is mark a question as offtopic. When they they do that an email gets sent to the person that asked the question telling them that their email is offtopic.
The email notification is sent via delayed_job:
QuestionMailer.delay.notify_asker_of_offtopic_flag question
However, on occasion someone might accidentally mark the question as offtopic or change their mind. To avoid an incorrect notification going to the person who originally asked it I want to create a short delay and evaluate whether the question is still offtopic when the mailer request runs:
Delayed call to mailer:
QuestionMailer.delay(run_at: Time.now + 3.minutes).notify_asker_of_offtopic_flag(question)
Mailer:
class QuestionMailer
...
def notify_asker_of_offtopic_flag question
if question.offtopic?
# do mailing
end
end
end
Unfortunately this isn't that simple since the if block simply causes an error which then causes delayed_job to retry the job again and again.
I'm now experimenting with some pretty roundabout methods to achieve the same end but I'd really like to find some way to abort the QuestionMailer action without triggering errors. Is this possible?
Dont delay the mailer then. Delay another class method in your Question class perhaps? Pass the id of the question and within that delayed method check if the question is still offtopic and if yes the send email synchronously.
Essentially, your notify_asker_of_offtopic_flag could be moved to your question model and then mailing is synchronous (i'm sure you'll rename your methods).
There is talk going on about preventing delivering by setting perform_deliveries to false within your mail action itself in core but i'm not 100% where or how that will end up.
#Aditya's answer was basically correct however I wanted to keep my methods on the Mailer object to keep things nice and tidy. This required a few extra hacks.
Create a new Class method in the mailer that CAN be delayed
The problem with trying to cancel an instance Mailer method is that it inherently triggers rendering and other things that stop the method from being aborted. However I would still like to keep all my Mailer logic together.
The way I did this was by using a class method instead of an instance method. This avoided all of the hooks that kick in when calling the method on an ActionMailer instance but still allowed me to keep the code tidy and together
class QuestionMailer
...
def notify_asker_of_offtopic_flag question
...
end
def self.notify_asker_of_offtopic_flag question_if question
if question.offtopic?
QuestionMailer.notify_asker_of_offtopic_flag question
end
end
end
NB fix for using delayed job
This works except for one slight hack that's necessary to deal with delayed_job.
When dealing with a Mailer, delayed_job will always call .deliver on the returned object in order to deliver the mail. This is fine when we return a mail object but in this case we're returning nil. delayed_job therefore tries to call .deliver on nil and everything fails.
In order to account for this we simply return a dummy mailer object containing a dupe .deliver method:
class QuestionMailer
...
class DummyMailer
def deliver
return true
end
end
def notify_asker_of_offtopic_flag question
# do mailing stuff
end
def self.notify_asker_of_offtopic_flag question_if question
if question.offtopic?
QuestionMailer.notify_asker_of_offtopic_flag question
else
DummyMailer.new
end
end
end

Resources