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 a lib/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
lib/tasks/user_tasks.thor
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
Since this example needs Active Record to function, I’ve added the 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
  # ...
Now the task can be invoked with 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"])
The 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"
Putting this at the top of every .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
Thorfile
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:

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.

Share this? Copy link

Feedback? Email me!

Hi! 👋 I’m Matt Brictson, a software engineer in San Francisco. This site is my excuse to practice UI design, fuss over CSS, and share my interest in open source. I blog about Rails, design patterns, and other development topics.

Recent articles

RSS
View all posts →

Open source projects

mattbrictson/rails-template

App template for Rails 7 projects; best practices for TDD, security, deployment, and developer productivity. Now with optional Vite integration! ⚡️

1,055
Updated 1 month ago

mattbrictson/tomo

A friendly CLI for deploying Rails apps ✨

360
Updated 20 days ago

More on GitHub →