Rake without Rails
- Publish Date
- Authors
- Justin Searls
A lot of ink has been spilled over how to get “real” unit tests working in Ruby
on Rails projects. The first time I encountered it was when Gary
Bernhardt and Corey
Haines started the “fast
specs” meme. Knowing both of them,
it was partially about improving runtime speed for faster feedback, but it was
also about gaining focus and simplicity by way of excluding the myriad global
constants that come along with require "rails"
.
Because RSpec has always shipped with is own (quite nice!)
CLI, setting up a test suite that didn’t load rails was as simple as creating a
one-off helper file and cordoning off a directory for rails-free tests (e.g.
rspec spec/fast
). For Minitest users running their tests with a Rake task, a
few more steps were needed, but it was still pretty straightforward.
But then, after the “movement” died down, Rails 4 arrived and a change to how it
configured Rake inadvertently made it really hard to run rake
without first
requiring the universe. In a Rails 4 app, the generated Rakefile looks like
this:
require File.expand_path('../config/application', __FILE__)
Rails.application.load_tasks
So, in the post-Rails 4 world, if you’re thinking, “oh, I’ll just write a custom
Rake task that only loads plain-ol’ Ruby objects in lib/
and save myself the
time to require Rails”, you may not realize that the very first thing your
Rakefile does is load all of Rails (and probably most of your Gemfile, too).
I’d completely forgotten about this today when I wrote a little Rake task to run
unit tests that didn’t depend on any Rails-aware code in
lib/tasks/unit_test.rake
:
require "rake/testtask"
Rake::TestTask.new(:unit_test) do |t|
t.warning = false
t.libs << "test"
t.libs << "app"
t.libs << "lib"
t.test_files = FileList['test/**/*_unit_test.rb']
end
I was feeling pretty clever, because any file ending in _unit_test.rb
would
belong to a logical test suite, even if intermingled with directories
containing Rails-aware tests. And, because _unit_test.rb
also ends in
_test.rb
, any unit tests would also be scooped up by our existing test
task—meaning I wouldn’t have to futz with CI configuration or, even better,
inform other developers on the project about what I was doing. (That last bit is
important: the number one reason I saw the “fast specs” meme fail in practice
was that less zealous team members would forget to run the extra test suite,
only to be surprised when “Chris’s suite” would fail in CI.)
Isolated Rake task in hand, I wrote a dummy test and ran it with time rake unit_test
:
# Running:
.
Finished in 0.001424s, 702.2472 runs/s, 702.2472 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
real 0m2.120s
Ruby isn’t the fastest interpreted language on the planet, but it’s surely fast enough to load Rake and run a two-line method in less than two seconds. Seeing this wall-time was a dead giveaway that Rails was being loaded before my task was even defined.
Aaron Patterson and I have investigated removing the Rails-generated Rakefile’s dependency on Rails in the past, but eliminating the application load from the Rake environment would be a nontrivial endeavor. And, at the end of the day, this isn’t really Rails’ problem. It’s a Rails app, it’s going to want to load Rails. News at 11.
So, where to turn if you want to run any tasks that are reliably divorced from
Rails (and your other gems that lack a require => false
directive in your
Gemfile)? It turns out you can always just write an additional Rakefile.
Rakefile.norails
The rake
cli takes a predictable --rakefile
argument, so if you write a
Rakefile.norails
, you can run it like this:
$ rake --rakefile Rakefile.norails
If you don’t find yourself writing many Rakefiles from scratch, you could get started by just cherry-picking the tasks you’d defined with the intention of not needing Rails, like so:
import "lib/tasks/unit_test.rake"
task :default => :unit_test
This way, the :unit_test
task will be available to both Rake configurations,
sparing your colleagues any grief if they forget about your custom Rakefile and
the two seconds it can save them. (If you’re befuddled why two seconds should
matter to you, check out this tiny portion
of my How to Stop Hating Your
Tests talk.)
New, isolated Rakefile in hand, let’s try to run it with rake --rakefile Rakefile.norails
:
# Running:
.
Finished in 0.001076s, 929.3680 runs/s, 929.3680 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
real 0m0.794s
Excellent, we cut our load time by more than half!
To figure out how low the floor is, we could cut it further by eliminating Rake
and running just a single test using the ruby
CLI. When I ran time ruby -I lib:test test/lib/simple_unit_test.rb
, it yielded a 0.423s
run time.
What if someone loads Rails anyway?
It would be tempting to call our work finished at this point, but there’s a lingering fear: what if one of these tests inadvertently requires some code that, in turn, results in Rails being loaded? It’s not hard to imagine that happening, and if just a single test were to accidentally load Rails, it would eradicate the intended benefit of this separate Rakefile.
So, can we ensure Rails isn’t loaded? We can! By using Ruby’s at_exit
hook, we
can add a check that the Rails
constant is still undefined after all of our
tasks are finished executing. Here’s what my final Rakefile.norails
file
looked like:
import "lib/tasks/unit_test.rake"
task :default => :unit_test
at_exit do
if defined?(Rails)
raise "Rails wound up getting loaded by a Rakefile.norails task! Failing!"
end
end
But that’s still not enough! Because Rake::TestTask
will spawn a separate test
process, we also need to install this trip-wire somewhere in our test suite. I
chose to throw it in my test/unit_test_helper.rb
file:
require "minitest/autorun"
class UnitTest < Minitest::Test
end
MiniTest.after_run do
if ENV['NO_RAILS'] && defined?(Rails)
raise "Rails wound up getting loaded by a unit test! Failing!"
end
end
Now, if either a test or a rake task were to load Rails, an error will be
raised and the process will exit non-zero. Note the ENV['NO_RAILS']
condition,
though. So that our unit tests won’t inadvertantly break our integrated rake
task (recall that rake test
will scoop up these new unit tests), we’ll need
some kind of flag to ensure we only trigger this alarm in the event that the
unit tests were launched via Rakefile.norails
.
With that caveat, here’s our final Rakefile.norails
file:
ENV['NO_RAILS'] = "true"
import "lib/tasks/unit_test.rake"
task :default => :unit_test
at_exit do
if defined?(Rails)
raise "Rails wound up getting loaded by a Rakefile.norails task! Failing!"
end
end
Rake off Rails
This approach to running tasks in isolation from Rails has a few benefits:
- Only takes a few minutes to set up
- Unit tests will also be run by the app’s existing test task
- If someone forgets
--rakefile Rakefile.norails
, they can still run theunit_test
task - If a “no rails” task or a test inadvertently loads Rails, we’ll be alerted immediately
For a real-world example, this blog post resulted from this actual commit in Test Double’s present app, which we use internally for tracking time & generating invoices.
Justin Searls
- Code Name
- Agent 002
- Location
- Orlando, FL