Search
⌘K
    to navigateEnterto select Escto close

    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/models/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/user_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).assigned_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).assigned_user # assignee of the generated task
    10    @nancy = create(:task).assigned_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
    15end

    Let us also test if user_notification is generated after an email regarding pending tasks has been sent to the user. Add the following test case to TaskMailerTest:

    1def test_task_mailer_after_action_create_user_notifications
    2  assert_equal 0, UserNotification.count
    3  TaskMailer.pending_tasks_email(@user.id).deliver_now
    4
    5  assert_equal 1, UserNotification.count
    6end

    Now, add a test case to verify that no email is being sent in case the receiver or user with given user_id is not present:

    1def test_task_mailer_shouldnt_deliver_email_if_receiver_is_not_present
    2  email = TaskMailer.pending_tasks_email("").deliver_now
    3  assert ActionMailer::Base.deliveries.empty?
    4  assert_nil email
    5end

    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"
    Previous
    Next