---
title: Test Rake Tasks Like a BOSS
teaser:
tags: web,rails,ruby,testing
author: Josh Clayton
published_on: 2011-10-26
---

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](http://rake.rubyforge.org/classes/Rake/Application.html#M000099)
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!
