rails
Tips for writing Rails tasks with Thor instead of Rake
Thor is a great way to write simple CLIs like one-off Rails scripts, but it does have its own gotchas. Here’s how to use Thor in practice.
What’s wrong with Rake?
Before diving into Thor, it’s worth reviewing Rake’s shortcomings as a CLI-builder. Long-story short:
- Accepting command-line arguments in a Rake task is awkward and conflicts with zsh; building a simple CLI script that accepts options and flags is prohibitively difficult.
- When defining helper methods within a Rake file, it is dangerously easy to inadvertently pollute the global Ruby namespace. This is so common that RuboCop has a suite of rules to guard against surprising Rake side-effects.
- Rake tasks are tricky to test, leading to lots of smart people working around the problem with different bits of RSpec shared-context magic.
Introduction to Thor
Thor is a library for building CLIs in Ruby. You are probably already using it. For example, commands like rails new
, rails console
, rails server
, and rails routes
are implemented with Thor behind the scenes. The thor
gem is maintained by the Rails core team and ships with every Rails app.
The basics of Thor are pretty straightforward:
- Create a CLI by subclassing
Thor
in alib/tasks/*.thor
file - Any public method decorated with
desc
becomes a CLI command - CLI arguments are passed into the method as normal Ruby method parameters
Let’s get into it!
An example Thor task
Let’s say you want to write a task for creating users in your Rails app. It should accept an email address on the command line and then prompt interactively for a password. Here’s the skeleton of that task in Thor:
class UserTasks < Thor
desc "create EMAIL", "Create a User record in the database identified by EMAIL"
def create(email)
# TODO
end
end
This defines a task that can be invoked with
thor user_tasks:create
and accepts a single command-line argument.
You can see all the CLI tasks that Thor knows about by running thor -T
:
$ thor -T
user_tasks
----------
thor user_tasks:create EMAIL # Create a User record in the database identified by EMAIL
Prompting for input
Thor has a built-in ask
method for requesting user input. You can use that to ask for the user’s password and then save it using Active Record:
require_relative "../../config/environment"
class UserTasks < Thor
desc "create EMAIL", "Create a User record in the database identified by EMAIL"
def create(email)
password = ask("Password for #{email}:", echo: false)
User.create!(email:, password:)
say("Created #{email}!", :green)
end
end
require_relative
statement to ensure Rails is loaded. You can use a Thorfile to eliminate this boilerplate.
Customizing the namespace
By default, Thor derives the CLI command based on the name of the class you’ve defined. You can customize it using the namespace
declaration, like this:
class UserTasks < Thor
namespace :users
# ...
thor users:create
. I prefer naming my Thor classes as *Tasks
so that they don’t collide with model classes or modules in the Rails app itself.
The documentation now reflects the users
namespace:
$ thor -T
users
----------
thor users:create EMAIL # Create a User record in the database identified by EMAIL
Writing tests
Testing a Thor task is as simple as loading the .thor
file and calling invoke
. No extra setup is needed.
load Rails.root.join("lib/tasks/user_tasks.thor")
UserTasks.new.invoke(:create, ["user@example.com"])
invoke
method mimics how the task will be called via the CLI. It takes the name of the task and an array of command-line arguments (if any).
Most of the complexity in testing CLIs involves stubbing I/O if your tasks are expecting user input or printing results. Thor wraps its I/O operations via a shell
object; you can stub those operations using your testing library of choice, as shown below.
Minitest
Minitest provides a capture_io
method that captures stdout and stderr. To simulate the user typing in their password, you can use stubs(...).returns(...)
from the mocha gem to stub Thor’s ask
method.
require "test_helper"
load Rails.root.join("lib/tasks/user_tasks.thor")
class UserTasksTest < ActiveSupport::TestCase
setup do
@user_tasks = UserTasks.new
end
test "creates a user given an email argument and password on stdin" do
@user_tasks.shell.stubs(:ask).returns("top!secret")
out, _err = capture_io { @user_tasks.invoke(:create, ["user@example.com"]) }
assert_match(/Created user@example.com!/, out)
user = User.find_by!(email: "user@example.com")
assert(user.valid_password?("top!secret"))
end
end
RSpec
Similarly, RSpec has an expect...to_output
expression to capture and inspect the task output. RSpec’s allow(...).to receive(...)
can be used to simulate the password entry.
require "rails_helper"
load Rails.root.join("lib/tasks/user_tasks.thor")
RSpec.describe UserTasks do
subject(:user_tasks) { described_class.new }
it "creates a user given an email argument and password on stdin" do
allow(user_tasks.shell).to receive(:ask).and_return("top!secret")
expect {
user_tasks.invoke(:create, ["user@example.com"])
}.to output(/Created user@example.com!/).to_stdout
user = User.find_by!(email: "user@example.com")
expect(user).to be_valid_password("top!secret")
end
end
Thor’s rough edges
Thor works well as a replacement for Rake, but it also has its rough edges. This section identifies a couple that you are likely to encounter.
Deprecation warning on error
If you run the thor users:create
task without specifying the email required argument, you’ll see this unfortunate deprecation message:
ERROR: "thor create" was called with no arguments
Usage: "thor users:create EMAIL"
Deprecation warning: Thor exit with status 0 on errors. To keep this behavior, you must define `exit_on_failure?` in `Thor::Sandbox::UserTasks`
You can silence deprecations warning by setting the environment variable THOR_SILENCE_DEPRECATION.
Repetitive Rails requirement
In practice, you’ll probably find that nearly all of your Thor tasks rely on Rails in some way. That means you have to write something like this in each .thor
file, to ensure Rails is loaded:
require_relative "../../config/environment"
.thor
file can get tedious.
You can solve both of these issues by introducing a Thorfile
.
Adding a Thorfile
Like Rake’s Rakefile
, a Thorfile
in the root of your project will be automatically loaded whenever you run a thor
command. You can use this to apply global behavior to your Thor scripts. Here’s what I use:
# Load the Rails environment
require_relative "config/environment" unless %w[-h --help help -T list -v version].include?(ARGV.first)
# Ensure tasks exit with non-zero status if something goes wrong
class << Thor
def exit_on_failure?
true
end
end
Rails can take a couple seconds to load. The
unless
condition is an optimization to make thor help
and thor -T
skip Rails so they run fast.
Further reading
Thor’s primary shortcoming is that the official documentation is sparse and outdated. I’ve been able to piece things together from the following sources:
- Move over Rake, Thor is the new King (Ben Simpson)
- Examples of
Thor
used in public GitHub repos (grep.app) - Thor wiki (GitHub)
- whatisthor.com (Official homepage; outdated)
- Thor::Actions (rubydoc)
- Thor::Shell::Basic (rubydoc)
- Thor::Shell::Invocation (rubydoc)
If you are starting a new Rails project, check out my rails-template app generator, which includes a Thorfile
and a bin/thor
Bundler binstub out of the box.