giant robots smashing into other giant robots

Written by thoughtbot

cpytel

How To Use Arguments In a Rake Task

I came across this today. You can write Rake tasks that accept arguments, called like this:

rake tweets:send[cpytel]

You define the rask task like this:

namespace :tweets do
  desc 'Send some tweets to a user'
  task :send, [:username] => [:environment] do |t, args|
    Tweet.send(args[:username])
  end
end

Unfortunately, zsh can’t parse the call to the rake task correctly, so you’ll see the error:

zsh: no matches found: tweets:send[cpytel]

So you’ll need to run it like this:

rake tweets:send\[cpytel\]

Or this:

rake 'tweets:send[cpytel]'

jdclayton

Test Rake Tasks Like a BOSS

Testing Rake tasks is one of the most painful things I do as a Ruby developer. Even after extracting all the code out into a separate class (which helps a lot), I still want to make sure I test that the right classes got called correctly with the right arguments.

I wanted the subject to be the task, where I could call invoke, check its prerequisites, etc.

describe "cron:hourly" do
  its(:prerequisites) { should include("reports:users") }
end

describe "reports:users" do
  before { ReportGenerator.stubs(:generate) }

  its(:prerequisites) { should include("environment") }

  it "generates the report" do
    subject.invoke
    ReportGenerator.should have_received(:generate).with()
  end
end

RSpec has shared contexts, so I set off to find an easy, straightforward way to test Rake tasks.

# spec/support/shared_contexts/rake.rb
require "rake"

shared_context "rake" do
  let(:rake)      { Rake::Application.new }
  let(:task_name) { self.class.top_level_description }
  let(:task_path) { "lib/tasks/#{task_name.split(":").first}" }
  subject         { rake[task_name] }

  def loaded_files_excluding_current_rake_file
    $".reject {|file| file == Rails.root.join("#{task_path}.rake").to_s }
  end

  before do
    Rake.application = rake
    Rake.application.rake_require(task_path, [Rails.root.to_s], loaded_files_excluding_current_rake_file)

    Rake::Task.define_task(:environment)
  end
end

This shared context is doing a lot, so I’ll walk through some of the odd areas and explain what’s happening.

The second let (task_name) is grabbing the top level description. That means it’ll use the text we pass to describe to calculate the task we’re going to run.

describe("reports:user") { } # subject is Rake::Task["reports:user"]

task_path is the path to the file itself, relative to Rails.root. We can infer path based off of the description, so for the describe above, it’ll assume the rake task is in lib/tasks/reports.rake.

Thirdly, loaded_files_excluding_current_rake_file - this requires a bit of explanation, even with that really descriptive method name. Rake is kind of a pain in certain cases; The rake_require method takes three arguments: the path to the task, an array of directories to look for that path, and a list of all the files previously loaded. rake_require takes loaded paths into account, so we exclude the path to the task we’re testing so we have the task available. This only matters when you’re running more than one test on a rake task, but there’s no harm in doing this every time we test so that there aren’t odd edge cases out there.

Finally, I define the :environment task (which most tasks defined in a Rails app will have as a prerequisite, since it’ll load the Rails stack for accessing models and code within lib without any additional work.

That’s the shared context in a nutshell; here’s what it allows us to do.

The tasks:

# lib/tasks/reports.rake
namespace :reports do
  desc "Generate users report"
  task :users => :environment do
    data = User.all
    ReportGenerator.generate("users", UsersReport.new(data).to_csv)
  end

  desc "Generate purchases report"
  task :purchases => :environment do
    data = Purchase.valid
    ReportGenerator.generate("purchases", PurchasesReport.new(data).to_csv)
  end

  desc "Generate all reports"
  task :all => [:users, :purchases]
end

And the tests:

# spec/lib/tasks/reports_rake_spec.rb
describe "reports:users" do
  include_context "rake"

  let(:csv)          { stub("csv data") }
  let(:report)       { stub("generated report", :to_csv => csv) }
  let(:user_records) { stub("user records for report") }

  before do
    ReportGenerator.stubs(:generate)
    UsersReport.stubs(:new => report)
    User.stubs(:all => user_records)
  end

  its(:prerequisites) { should include("environment") }

  it "generates a registrations report" do
    subject.invoke
    ReportGenerator.should have_received(:generate).with("users", csv)
  end

  it "creates the users report with the correct data" do
    subject.invoke
    UsersReport.should have_received(:new).with(user_records)
  end
end

describe "reports:purchases" do
  include_context "rake"

  let(:csv)              { stub("csv data") }
  let(:report)           { stub("generated report", :to_csv => csv) }
  let(:purchase_records) { stub("purchase records for report") }

  before do
    ReportGenerator.stubs(:generate)
    PurchasesReport.stubs(:new => report)
    Purchase.stubs(:valid => purchase_records)
  end

  its(:prerequisites) { should include("environment") }

  it "generates an purchases report" do
    subject.invoke
    ReportGenerator.should have_received(:generate).with("purchases", csv)
  end

  it "creates the purchase report with the correct data" do
    subject.invoke
    PurchasesReport.should have_received(:new).with(purchase_records)
  end
end

describe "reports:all" do
  include_context "rake"

  its(:prerequisites) { should include("users") }
  its(:prerequisites) { should include("purchases") }
end

Some people may say, “This is overkill! I tested the classes in other areas!” To me, that’s just like saying, “I’ve written unit and functional tests so I don’t need to write integration tests.” If you have a rake task that needs to be run (cron on Heroku, for example), would you leave that code untested? I wouldn’t.

Have you extracted out a pattern for testing Rake tasks? I’d love to hear about it; maybe a patch to RSpec is in order!

lolconomy

Testing Rake’s Integration

So now you’re integration testing, because that’s what cool kids are doing these days. This tests the joints of your app, making sure that the model code is being called from the controller code which is being invoked by the user.

A good integration test, as we all know, is from the user’s perspective: “I click this”, “I fill in that”, etc.

But what of rake tasks? Those are integration points. If they go wrong you’re depending on cron telling you, or maybe it’s a task you run once a year by hand. Either way, you want to test it.

We already move all of the rake task into the model. This simplifies unit testing; rake tasks are now just one method call, and that method is isolation tested.

Here’s an integration test for a rake task that sends an email with all new users:

require 'test_helper'

class DailyEmailReportTest < ActionController::IntegrationTest
  # In order to keep updated, I should see an email
  # of all new users' profile in the past day

  should "send an email from the rake test for the specific task" do
    old_user = Factory(:user,
                       :created_at => 2.days.ago,
                       :description => "old description")
    new_user = Factory(:user,
                       :created_at => 1.hour.ago,
                       :description => "new description")
    ActionMailer::Base.deliveries.clear

    i_call_rake_task "notifications:daily:users"

    i_see_in_email "new description"
    i_do_not_see_in_email "old description"
  end
end

Two test helpers are defined to help out: #i_see_in_email and #i_do_not_see_in_email. In addition to those (they just look over ActionMailer::Base.deliveries), we have #i_call_rake_task. That’s where the magic happens.

The first step is to override Rake::Task#invoke_prerequisites to avoid reloading the environment:

require 'rake'

# Do not re-load the environment task
class Rake::Task
  def invoke_prerequisites(task_args, invocation_chain)
    @prerequisites.reject{|n| n == "environment"}.each do |n|
      prereq = application[n, @scope]
      prereq_args = task_args.new_scope(prereq.arg_names)
      prereq.invoke_with_call_chain(prereq_args, invocation_chain)
    end
  end
end

Then we define the helper:

class Test::Unit::TestCase
  def i_call_rake_task(task_name)
    # Make sure you're in the RAILS_ROOT
    oldpwd = Dir.pwd
    Dir.chdir(RAILS_ROOT)

    # Get an instance of rake
    rake_app = Rake.application
    rake_app.options.silent = true

    # Back to where you were
    Dir.chdir(oldpwd)

    rake_app.init
    rake_app.load_rakefile

    task = rake_app.tasks.detect {|t| t.name == task_name}
    assert_not_nil task, "No rake task defined: #{task_name}"
    task.reenable
    task.invoke
  end
end

With this in place, you can be more sure that rake tasks are integrated with your system. Wee!

Jumping into a pile of leaves