Learn Ruby on Rails Book

Testing Sidekiq based email delivery feature

In this chapter, we are going to create the test cases for the models, workers and services that we had created in the previous chapter.

Adding tests for the User model

Let's incrementally add the following test cases into test/model/user_test.rb.

Let's test if user preference is created when a new user is created:

1def test_preference_created_is_valid
2  @user.save
3  assert @user.preference.valid?
4end

Let's add another test to verify the default preference saved for the user is the same as the DEFAULT_NOTIFICATION_DELIVERY_HOUR which we defined in our config/initializers/constants.rb file:

1def test_notification_delivery_hour_uses_default_value
2  @user.save
3  assert_equal @user.preference.notification_delivery_hour, Constants::DEFAULT_NOTIFICATION_DELIVERY_HOUR
4end

We have used the assert_equal method to check if the two values provided are equal.

Adding tests for the Preference model

Open test/models/preference_test.rb and let's create a preference instance in the setup method so that it's available to all the test cases:

1require 'test_helper'
2
3class PreferenceTest < ActiveSupport::TestCase
4  def setup
5    user = create(:user)
6    @preference = user.preference
7  end
8end

Let's test that a preference is invalid if notification_delivery_hour is not present.

Add the following code to the test file:

1def test_notification_delivery_hour_must_be_present_and_valid
2  @preference.notification_delivery_hour = nil
3  assert @preference.invalid?
4  assert_includes @preference.errors.messages[:notification_delivery_hour], t('errors.messages.blank')
5end

In the above code, we have used assert_includes.

The assert_includes assertion takes in two arguments and checks whether the second argument is included in the first argument, which is a collection.

We have used errors.messages, to get all the errors in hash format.

In this hash, the key is the attribute's name and its value is an array of error messages specific to that attribute.

Below is an example showing how the hash value looks like:

1> @preference.errors.messages
2{
3  :notification_delivery_hour=>["Notification Delivery Hour is required."]
4}

Here, since preference is invalid, the model should be ideally raising a validation error.

We are just making sure that the error is actually raised and the message raised is the same as what we had.

In our Preference model the value of notification_delivery_hour should only take in default business hour values which are in the range of 0 to 23.

Thus we have to test that the Preference model will be invalid if an invalid value is provided to notification_delivery_hour.

When testing for invalid values, we should always prefer to test the n-number of such values.

This is to make sure that we are weeding out any edge cases. The same logic applies when testing valid values.

Add the following code to the test file:

1def test_notification_delivery_hour_should_be_in_range
2  invalid_hours = [-10, -0.5, 10.5, 23.5, 24]
3
4  invalid_hours.each do |hour|
5    @preference.notification_delivery_hour = hour
6    assert @preference.invalid?
7  end
8end

Adding tests for the UserNotification model

Let us first create a new factory to generate instances of UserNotification model. Create and open test/factories/notification.rb and add the following contents to it:

1# frozen_string_literal: true
2
3FactoryBot.define do
4  factory :user_notification do
5    user
6    last_notification_sent_date { Time.zone.today }
7  end
8end

As you can see, we haven't used faker here. We can pass any Ruby expression as the fields value for generated objects in the factory definition.

Open test/models/user_notification_test.rb and let's create setup for the test cases:

1require 'test_helper'
2
3class UserNotificationTest < ActiveSupport::TestCase
4  def setup
5    @user_notification = create(:user_notification)
6  end
7end

In the above code, we have used Time.zone.today instead of using the default Ruby Time, Date and DateTime classes.

If we use Ruby default classes, it will not show time in the time zone specified by config.time_zone in application.rb.

That's the reason why we should always use Active Support methods of Time.zone to pick up the Rails time zone.

Let's test that a user_notification is invalid if last_notification_sent_date is not present:

1def test_last_notification_sent_date_must_be_present
2  @user_notification.last_notification_sent_date = nil
3  assert @user_notification.invalid?
4  assert_includes @user_notification.errors.messages[:last_notification_sent_date], t('errors.messages.blank')
5end

Let's add another test to check last_notification_sent_date must be a parsable date:

1def test_last_notification_sent_date_must_be_parsable
2  # The value of date becomes nil if provided with invalid date.
3  # https://github.com/rails/rails/issues/29272
4
5  @user_notification.last_notification_sent_date = "12-13-2021"
6  assert_nil @user_notification.last_notification_sent_date
7end

When we provide a string value to a datetime field, Rails will try to parse that string to make sure it's a valid date.

In the case where the string passed in is not a valid date, Rails will store nil value to that particular field. That's the default Rails behaviour.

This is actually the way Rails typecasts.

We can use the _before_type_cast accessor to apply the validation. But since that brings in some obfuscation, let's go with the default behaviour of Rails.

In our test case, we are trying to store a date string whose month is 13, which is invalid. Thus Rails will typecast it into nil when storing it in the field.

So that is the reason why we are using the assert_equal method for last_notification_sent_date attribute to verify it is nil for an invalid date.

Let's add another test to make sure user_notification is invalid if last_notification_sent_date is set to a past date:

1def test_last_notification_sent_date_cannot_be_in_past
2  @user_notification.last_notification_sent_date = Time.zone.yesterday
3  assert @user_notification.invalid?
4  assert_includes @user_notification.errors.messages[:last_notification_sent_date], t('date.cant_be_in_past')
5end

Adding tests for the TodoNotificationsWorker

Open test/workers/todo_notifications_worker_test.rb and paste the following into it:

1# frozen_string_literal: true
2
3require 'test_helper'
4
5class TodoNotificationsWorkerTest < ActiveSupport::TestCase
6  def setup
7    @user = create(:task).user # assignee of the generated task
8    default_mail_delivery_time = "#{Constants::DEFAULT_NOTIFICATION_DELIVERY_HOUR}:00 AM"
9    travel_to DateTime.parse(default_mail_delivery_time)
10  end
11
12  def test_todo_notification_worker_sends_email_to_users_with_pending_tasks
13    assert_difference -> { @user.user_notifications.count }, 1 do
14      TodoNotificationsWorker.perform_async
15    end
16  end
17end

In the above test case, we have populated the required models in the setup method, and then tested whether our worker is actually sending mails successfully, if it's the actual delivery time.

As you can see, we are using the travel_to helper in our setup method.

travel_to helper changes the current time to the given time by stubbing Time.now, Date.today, and DateTime.now to return the time or date passed into this method. The stubs are automatically removed at the end of the test.

We know that the default mail delivery time is 10 AM. So if the worker runs at this specific time, then it should be able to send the mail.

Thus, in our test case, instead of waiting for the time in our system to be 10 AM, we are temporarily setting the time as 10 AM by using the travel_to helper.

assert_difference method allows us to check that a value has changed by a given amount after a block has been executed.

Here in our code, we passed the lambda function -> { @user.user_notifications.count } which returns the total records present in the user_notifications table for that user.

Lambda functions are the method created using the literal lambda operator -> {}.

You can also use the lambda keyword instead of -> but the literal operator is succinct and commonly used.

Once the mail is successfully sent to the user, the last_notification_sent_date attribute in the user_notifications table will be set with today's date for that particular user.

That's the reason why we are asserting that the count in user_notifications for that particular user has been changed by one, given that we are only sending one mail.

Adding tests for the UserNotificationsWorker

Open test/workers/user_notifications_worker_test.rb and paste the following into it:

1require 'test_helper'
2class UserNotificationsWorkerTest < ActiveSupport::TestCase
3  def setup
4    @user = create(:user)
5  end
6
7  def test_task_mailer_jobs_are_getting_processed
8    assert_difference -> { @user.user_notifications.count }, 1 do
9      UserNotificationsWorker.perform_async(@user.id)
10    end
11  end
12end

Similar to how we tested TodoNotificationsWorker, here also we used the assert_difference method to check whether the mail has been successfully sent to the user.

Creating support or helper files for tests

In Rails, we can create support files for testing. These are helper modules which can be used by any test file by importing it.

Let's create the support directory which will have the helper modules.

An example of such a support test module is a SidekiqHelper.

When testing Sidekiq queues, we are often required to manipulate the Redis queue before and after the test cases. Rather than doing it manually each time, we delegate it to a support method:

1mkdir -p test/support
2touch test/support/sidekiq_helper.rb

The following is an example of how the module, which will be located at test/support/sidekiq_helper.rb, will look like. You don't have to create this file since we won't be testing any Sidekiq queue related processing.

1# frozen_string_literal: true
2
3module SidekiqHelper
4  def clear_redis_data
5    Sidekiq.redis do |conn|
6      conn.keys("cron_job*").each do |key|
7        conn.del(key)
8      end
9    end
10  end
11
12  def clear_sidekiq_queues
13    Sidekiq::Queue.all.each do |queue|
14      queue.clear
15    end
16  end
17
18  def after_teardown
19    Sidekiq::Worker.clear_all
20    super
21  end
22end

In the cases where you'd want to use these support modules, we have to require it in the test file and include it within the class.

Here is an example of importing the helper module:

1require  "support/sidekiq_helper"
2
3class TodoNotificationServiceTest < ActiveSupport::TestCase
4  include SidekiqHelper
5end

Requiring these modules manually in the test files can be a repetitive chore.

Thus in cases where we want to use support modules, we can automate the require part, by adding the following in our test_helper:

1Dir[Rails.root.join("test/support/**/*.rb")].each { |f| require f }

Note that adding the above statement, would only automatically require the files.

You'd still have to manually include the required modules within your test class when needed.

Adding tests for the TodoNotificationService

As we mentioned in the previous chapter, there is no Rails generator for services.

So let's create the services directory and a testing file for TodoNotificationService in it:

1mkdir -p test/services
2touch test/services/todo_notification_service_test.rb

While testing this service we are going to make sure that the user preferences are always respected before sending mail.

We will also ensure that idempotency is handled.

Open the file and paste the following into it:

1# frozen_string_literal: true
2
3require "test_helper"
4require  "support/sidekiq_helper"
5class TodoNotificationServiceTest < ActiveSupport::TestCase
6  include SidekiqHelper
7
8  def setup
9    @sam = create(:task).user # assignee of the generated task
10    @nancy = create(:task).user # assignee of another generated task
11
12    default_mail_delivery_time = "#{Constants::DEFAULT_NOTIFICATION_DELIVERY_HOUR}:00 AM"
13    travel_to DateTime.parse(default_mail_delivery_time)
14  end
15
16  def test_notification_worker_is_invoked_for_users_receiving_mail_for_first_time
17    assert_difference -> { @sam.user_notifications.count }, 1 do
18      todo_notification_service.process
19    end
20  end
21
22  def test_notification_worker_is_invoked_for_users_according_to_delivery_hour_preference
23    delivery_hour_in_future = Constants::DEFAULT_NOTIFICATION_DELIVERY_HOUR + 1
24    @sam.preference.update(notification_delivery_hour: delivery_hour_in_future)
25
26    assert_difference -> { UserNotification.count }, 1 do
27      todo_notification_service.process
28    end
29  end
30
31  def test_notification_worker_is_invoked_only_for_users_with_receive_email_enabled
32    @sam.preference.update(receive_email: false)
33
34    assert_difference -> { UserNotification.count }, 1 do
35      todo_notification_service.process
36    end
37  end
38
39  def test_notification_worker_is_invoked_only_for_users_yet_to_receive_notification_today
40    create(:user_notification, user: @sam)
41
42    assert_difference -> { UserNotification.count }, 1 do
43      todo_notification_service.process
44    end
45  end
46
47  private
48
49    def todo_notification_service
50      TodoNotificationService.new
51    end
52end

Adding tests for the TaskMailer

We can also write a test case for our TaskMailer to check if ActionMailer is actually delivering our mails or not.

Open test/mailers/task_mailer_test.rb and paste the following into it:

1require 'test_helper'
2
3class TaskMailerTest < ActionMailer::TestCase
4  def setup
5    @user = create(:user)
6  end
7
8  def test_task_mailer_is_delivering_mails
9    email = TaskMailer.pending_tasks_email(@user.id).deliver_now
10    assert_not ActionMailer::Base.deliveries.empty?
11    assert_equal ["no-reply@granite.com"], email.from
12    assert_equal [@user.email], email.to
13    assert_equal "Pending Tasks", email.subject
14  end
15
16  def test_task_mailer_after_action_create_user_notifications
17    assert_equal 0, UserNotification.count
18    TaskMailer.pending_tasks_email(@user.id).deliver_now
19
20    assert_equal 1, UserNotification.count
21  end
22end

In the above code, we have used the pending_tasks_email method created in TaskMailer, which will find the user with the user id and all the pending tasks associated with that user, to send the mail.

Also, we have used deliver_now on the TaskMailer to send the mail.

We can also use the deliver_later helper method to enqueue the mail to be delivered through Active Job. When the job runs it will send the mail using deliver_now.

Here, we are using the deliver_now method, as we did not want to wait for mail delivery.

ActionMailer::Base.deliveries keeps an array of all the mails sent out through the ActionMailer.

Thus we are asserting that after successfully sending the mail ActionMailer::Base.deliveries? is not empty, since it's an array of emails already sent out.

This was the last test case. So we have completely tested and fortified our Sidekiq email sending feature.

You can run all the test cases by using bundle exec rails t -v.

You can also append a specific test file's path to this command to make it run only a single test file.

Now let's commit these changes:

1git add -A
2git commit -m "Added test cases for sidekiq email sending feature"
⌘K
    to navigateEnterto select Escto close
    Previous
    Next