Michael Anhari

Breaking the space-time continuum in your test suite with Timecop

An hourglass sitting off-keel atop some small rocks.
Image by Aron Visuals on unpslash.com

The other day I was working on a feature that required emailing out a weekly summary excel file to interested parties every Monday morning. I added the ruby class for building the csv, added the rails action mailer, and generated a rails custom rake task that I could schedule from Heroku's free scheduler.

Unfortunately the Heroku scheduler only supports triggering tasks every 10 minutes, every hour, or every day.

A screenshot Heroku’ s scheduler only allowing scheduling options of “Every 10 minutes”, “Every hour at…”, and “Every day at…”

I started running through my options.

  1. Find a different addon for triggering tasks
  2. Write a bash command that would check the date and use that to run the rake task
  3. Write some Ruby that would cause the rake task to be a no-op unless it was triggered on the right day

I opted for option three for a few reasons.

  • We're already using the Heroku scheduler and adding a second addon seemed heavy handed for this use case
  • Writing the bash command would be harder to duplicate the next time we have this need (we'd have to scroll through our jobs in the Heroku scheduler, or figure out a new command for each day of the week using some not-so-easy-to-parse bash)
  • Writing it in Ruby is the easiest way to test the functionality and have the team expand upon it

Adding a TemporalTask module

The implementation was a good candidate for using ruby's ability to pass blocks to methods. Here's how our new TemporalTask module turned out:

module TemporalTask
  def self.on_the_first_of_the_month(&block)
    if Date.today.beginning_of_month == Date.today
      yield if block
    else
      puts "it's not the first of the month"
    end
  end

  def self.on_mondays(&block)
    if Date.today.monday?
      yield if block
    else
      puts "it's not Monday"
    end
  end
end

Right now, these are the only two methods we need in our code base, but you can see how easy it would be to implement this task for other weekdays (or even refactor it to take in a weekday as a symbol, i.e. :tuesday). Here's some similar code to how the rake task was written:

namespace :metric_reports do
  desc "Sends metrics for the previous week"
  task weekly_summary: :environment do
    TemporalTask.on_mondays do
      Metrics::WeeklySummaryWorker.perform_async
    end
  end
end

Testing our new module

Testing this module will involve manipulating the current date, and that's where timecop comes in. Timecop is a gem with a lot of functionality for manipulating a test's concept of the current date and time, but for the vast majority of use cases you'll just need to now how to use it's freeze function as a block.

  Timecop.freeze(Time.local(2021, 8, 1,  1, 0 0)) do
    # do something on August 1st, 2021 at 1AM
  end

The freeze method in block form is a safe operation. Time is only manipulated inside the bounds of the do end block. Let's use it to test our TemporalTask.on_mondays method:

require "rails_helper"

describe TemporalTask do
  describe ".on_mondays" do
    it "prints out a notice if it's not a monday" do
      wednesday = Time.local(2021, 7, 21, 10, 5, 0)
      Timecop.freeze(wednesday) do
        expect {
          TemporalTask.on_mondays do
            "it worked!"
          end
        }.to output("it's not Monday\n").to_stdout
      end
    end

    it "executes the block if it's Monday" do
      monday = Time.local(2021, 7, 19, 10, 5, 0)
      Timecop.freeze(monday) do
        expect {
          TemporalTask.on_mondays do
            print "it worked!"
          end
        }.to output("it worked!").to_stdout
      end
    end
  end
end

And there you have it! We now have an easy API for only running code on Mondays even though we'll trigger this task every day.

Conclusion

This worked very well for our application. I don't feel great about triggering tasks and ignoring them, but I wasn't happy with the other options I laid out earlier either. Until the Heroku scheduler adds support for more granular task control, or our app runs into issues with this solution, I think we'll stick with this pattern. So far, it's gone off without a hitch.

Newsletter

I'm working on sending out a weekly newsletter. I'll make it as easy as possible to unsubscribe at any time.