Customizing Rake Tasks In Rails 4.1 And Higher

I have been overriding, invoking, and executing custom Rake tasks since I was an early Ruby developer. Tweaking your project's automated tasks are likely the closest thing Rails developers come to building their own light saber. Most popular are adding or changing how the Rails test suite behaves. For example, adding Capybara to your project.

Recently I have been upgrading projects from 3.2 to 4.2 and one thing that really stood out to me was how Rails testing tasks are created and run. Most obvious is that the default test task now runs all model, controller, mailer, helper, job, and integration tests in a single process now. Upon investigation, I found that these major changes were introduced in Rails 4.1. Go check out their new testing.rake file if you are interested. Much cleaner than before!

So what does this mean for you? Let me describe a real scenario I had to address in one of my projects. This project uses two custom test additions. The first is called Konacha, a gem that leverages the asset pipeline to easily test my projects JavaScript with Mocha & Chai under Capybara with PhantomJS. The second addition uses the Capybara::DSL within the standard Rails test/integration namespace and directory structure.

When moving to Rails 4.2 I found that my integration tests were mixed together with everything else. This was a problem since I really wanted my slower integration tests to run after all my other tests. No matter what I tried, I could not get the setup I wanted. So like most problems, I solved this by diving into the code and learning.

Deconstructing Rails::TestTask

So we already had a look at the new testing.rake file above. But looking closer, we can see how the default test task is defined right at the top. Also of interest to us is that test:run task.

require 'rake/testtask'
require 'rails/test_unit/sub_test_task'

task default: :test

desc "Runs all tests in test folder"
task :test do
  Rails::TestTask.test_creator(Rake.application.top_level_tasks).invoke_rake_task
end

namespace :test do
  # ...
  Rails::TestTask.new(:run) do |t|
    t.pattern = "test/**/*_test.rb"
  end
  # ...

That description for test is pretty helpful and quite accurate. Indeed even though we have distinct test tasks like test:models and test:mailers, this default task is somehow aggregating each into a single run. To learn more about this implementation, we need to open up the sub_test_task.rb file required at the top.

In here we can see two new utility classes. One called TestCreator and another called TestInfo. The TestCreator#invoke_rake_task instance method is what we came here for.

def invoke_rake_task
  if @info.files.any?
    create_and_run_single_test
    reset_application_tasks
  else
    Rake::Task[ENV['TEST'] ? 'test:single' : 'test:run'].invoke
  end
end

Without knowing too much about the @info object initialized by TestCreator, we can make a pretty good guess that test:run is what we came here to find. By looking back at the test:run tasks pattern of "test/**/*_test.rb" it seems clear that this is the test responsible for running all of our tests at once. Knowing that, let's start adding code to the project's Rakefile and make our new tests tasks do our bidding.

Customizing Your Rakefile

Here is the first cut at customizing our Rails project's Rakefile. These come right after the default Rails.application.load_tasks line.

Rake::Task['test:run'].clear

namespace :test do

  task 'js' => 'konacha:run'

  Rails::TestTask.new(:_run) do |t|
    t.test_files = FileList['test/**/*_test.rb'].exclude(
      'test/integration/**/*_test.rb'
    )
  end

  task :run => ['test:js', 'test:_run', 'test:integration']

end

The first thing that is happening is we want to remove the existing test:run task using the clear method. We then open up the test task namespace and make a few additions. In order they are:

  1. Create a simple test:js alias for the Konacha provided konacha:run.
  2. Create our test:run replacement but now named test:_run. Note how we use test_files vs a pattern so we can use the exclude method on FileList to remove all integration tests.
  3. Make a new test:run task that calls each other task in the order we want.

If you were to run rake test now, we would see that our integrations are still inter-mixed with all the other Rails test cases. But why!?!

Final Rakefile Separating Integrations

So why did our integration tests still run with all the other Rails test cases? The answer lies in one simple override that Rails::TestTask implements. Normally the Rake::TestTask calls an instance method named define which basically builds a string and executes a Ruby subprocess. The Rails::TestTask class implements the define method like so:

def define
  task @name do
    if ENV['TESTOPTS']
      ARGV.replace Shellwords.split ENV['TESTOPTS']
    end
    libs = @libs - $LOAD_PATH
    $LOAD_PATH.unshift(*libs)
    file_list.each { |fl|
      FileList[fl].to_a.each { |f| require File.expand_path f }
    }
  end
end

See what is happening there? At the end of the day, all Rails::TestTask tasks do is require the test files. So now it makes perfect sense as to why it appeared that each task was being merged. With this knowledge, we can now customize our setup.

Rather than using Rails test task, we will just bail out and use the default Rake::TestTask instead. When doing so, we will have to push the test directory to the libs too. This is one of the few things done for us by Rails::TestTask.

Rake::Task['test:run'].clear
Rake::Task['test:integration'].clear

namespace :test do

  task 'js' => ['konacha:run']

  Rake::TestTask.new(:_run) do |t|
    t.libs << "test"
    t.test_files = FileList['test/**/*_test.rb'].exclude(
      'test/integration/**/*_test.rb'
    )
  end

  Rake::TestTask.new('integration' => 'test:prepare') do |t|
    t.libs << 'test'
    t.pattern = 'test/integration/**/*_test.rb'
  end

  task :run => ['test:js', 'test:_run', 'test:integration']

end

Now we are cooking. All of our JavaScript tests will run first, then all normal Rails test cases, and finally our integration tests. By customizing the test:run task we get the full benefit of the default test task implementation which allows us to pass file and/or TESTOPTS arguments. This happens to be one of my favorite features of Rails 4.1 and up too.

$ rake test test/models/user_test.rb
$ rake test test/integration/app_stories_test.rb

And it all just works! Thanks for reading!


by Ken Collins