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.
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.
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:
test:js
alias for the Konacha provided konacha:run
.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.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!?!
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!