How to Write Rock Solid Rake Tasks

by Brad Bollenbach

As developers, we spend a lot of time thinking about and debating software best
practices. We espouse the benefits of small classes made up of small methods,
clean separation of concerns, clear, precise names for things, and the
importance of writing tests before writing actual code.

But all that seems to fly out the window when it comes to rake tasks. Crack
open your lib/tasks folder, and you're almost guaranteed to find stuff like
this:

In fact, this example is generous. It's more likely that you'll find some rake
tasks in your project that are two or three times the size of this, if not
more. But even this short example shares the same two problems that are often
present in "real world" rake task code:

Poor abstractions. Rake tasks written in this "throwaway" style have no
abstractions - they're just a big hunk of code inside a task block, which
increases the cognitive load required to understand them.

Lack of tests. Worse still, they're unlikely to be tested. Sure, you
could write a test that calls Rake::Task['my_task_name'].invoke and test
them that way, but that's uncommon in my experience. Further, without
abstractions, such tasks are likely to be awkward to test anyway (difficult to
set up, stub, etc.) The lack of tests makes your rake tasks brittle and hard to
maintain.

Thankfully, if you do consider your rake tasks to be suffering from neglect,
and are looking for a way to improve them, there is a really simple rule you
can follow to make things better.

Limit Rake Task Bodies to a Single Method Call

An effective way to write rock solid rake tasks is to reduce the task to a
single method call, and write tests for that method. There are a few sticking
points with rake tasks that seem to make them harder to test than "normal"
code. Just like our example above, rake tasks often:

Manipulate objects on the filesystem.

Write to stdout.

Take parameters as environment variables, or in the rake foo[bar,baz] form.

With that in mind, let's look at one simple approach you can use to test rake
tasks like this. I'm going to use minitest, but the same ideas apply to
rspec or whatever other framework you prefer. This is by no means the One
True Way to test rake tasks, but it is an approach that's worked well for
several years for me.

The File System Is Your Friend

Developers sometimes get weird about reading from and writing to the filesystem
in a test, as if this will somehow make your tests non-deterministic, or
difficult to maintain. Hence hackarounds like fakefs.

IMHO, this fear is unwarranted. When you need to test code that does file IO,
using the actual filesystem is far less bug-prone than relying on monkey
patches to core file manipulation APIs.

Take the Output Stream as an Argument

Lastly, if your rake task writes to stdout, you probably want to actually test
the printed output, while also avoiding polluting your test runs with that
output. To do that, take the output stream as an argument, and then call puts
on that object. Example:

Putting It All Together

So bringing these ideas together, what do we end up with? First, we have a test
where no test existed before:

require'minitest/autorun'require'active_support/all'require'climate_control'describeStalePdfCleanerdodescribe'.clean!'dobeforedo@test_dir=Pathname.new(FileUtils.mkdir_p("/tmp/bugroll-examples/test_pdfs").first)FileUtils.rm(Dir.glob("#{@test_dir}/*.pdf"))@files=[]@files<<create_file(@test_dir.join("pdf1.pdf"),mtime:1.hour.ago)@files<<create_file(@test_dir.join("pdf2.pdf"),mtime:2.days.ago)@files<<create_file(@test_dir.join("pdf3.pdf"),mtime:10.hours.ago)endafterdo@files.each{|f|f.deleteiff.exist?}endit"must raise an ArgumentError if PDF_DIR is not specified"doproc{StalePdfCleaner.clean!}.must_raise(ArgumentError)endit"must remove pdfs that are more than one day old"doout=StringIO.newClimateControl.modify(PDF_DIR:@test_dir.to_s)doStalePdfCleaner.clean!(out)endout.rewindout.read.must_equal"Removed 1 stale pdf(s)\n"Dir.glob("#{@test_dir}/*.pdf").map{|fn|Pathname.new(fn).basename.to_s}.must_equal%w(pdf1.pdf pdf3.pdf)endendprivatedefcreate_file(file_path,options={})Pathname.new(FileUtils.touch(file_path,options).first)endend

Next, we have a small, simple class to contain the logic for our rake task:

Even if your rake task doesn't read from environment variables, or write to
stdout, or do file IO, you'll still get all the same benefits from this process
of turning your tasks "inside out", reducing them to a single method call each,
and writing a test against it. Tasks written this way are easier to read, and
can be modified with confidence.

Want more programming tips and techniques?

I'm writing a book called Rock Solid Rails Development: A No-Nonsense
Guide to Building High-Quality Rails Apps.
Enter your email to get more exclusive articles by email, and find out
as soon as the book's released!