Best practices: Writing a Rails script (and how to test it)

Posted . Visible to the public. Repeats.

A Rails script lives in lib/scripts and is run with bin/rails runner lib/scripts/.... They are a simple tool to perform some one-time actions on your Rails application. A Rails script has a few advantages over pasting some prepared code into a Rails console:

  • Version control
  • Part of the repository, so you can build on previous scripts for a similar task
  • You can have tests (see below)

Although not part of the application, your script is code and should adhere to the common quality standards (e.g. no spaghetti code). However, a script has a few special requirements. Please consider these when writing a Rails script.

Be idempotent

Your script may crash while halfway through its task. Running it again should not break anything, but continue where it left off.

Be atomic

Use database transactions for atomic changes, i.e. changes that must happen together or not at all.

ActiveRecord::Base.transaction do
  # Change something
  # Another, dependent change 
end

Be transparent

The script should always state what it is about to do, and await confirmation from the user. If a decision is needed, it should print all relevant information.

You can copy interaction helpers from Geordi Show archive.org snapshot .

When processing lists of records, print an identifier for each item. This way, you can watch it progress, and you'll know what the script was at in case it crashes.

Be robust

Your script will encounter invalid records, and record.save! will crash your script. Either use if record.save, or wrap the respective code like this:

begin
  # If using transactions, nest them here
  # ... record.save! ...
rescue ActiveRecord::RecordInvalid
  # Handle error
end

On error, log the record for manual care.

Consider testing your script

Not every script needs a test. However, sometimes you'll want to add some. Here is how. (Inspired by Functional core, imperative shell Show archive.org snapshot .)

  • Structure your script like this:
    # This class will be specced
    # To simplify this, it should not read input
    class YourScript
      # Methods offering functionality
    end
    
    unless Rails.env.test?
      script = YourScript.new
      # All interactivity (i.e. reading input) goes here
    end
    
  • Place tests in lib/scripts/spec and call them my_script_spec.rb. This way they're close to their corresponding script, but not included in regular full-app test runs. They can prove correctness of the script, while not breaking your test suite should your script outdate.
  • Structure your test like this:
    require_relative '../your_script_name'
    
    describe 'the purpose of your script' do
      subject { YourScript.new }
    
      it 'has output' do
        expect { subject.print_summary }.to output(/success/).to_stdout
      end
    end
    

Further reading

Dominik Schöler
Last edit
Michael Leimstädtner
License
Source code in this card is licensed under the MIT License.
Posted by Dominik Schöler to makandra dev (2024-01-30 07:00)