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
Thorin alib/tasks/*.thorfile - Any public method decorated with
descbecomes 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"
require "thor"
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"
require "thor"
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
Thorused 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 nextgen, my interactive Rails app generator, which has an option to include a Thorfile and a bin/thor Bundler binstub out of the box.