Breaking the space-time continuum in your test suite with Timecop
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.
I started running through my options.
- Find a different addon for triggering tasks
- Write a bash command that would check the date and use that to run the rake task
- 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.