< Prev^ UpNext >

Setup

Bundler and rvm: Tools For Keeping Things Neat

Before actually proceeding to that app code, there's some basic infrastructure to set up. I am a fan of rvm for keeping my projects isolated from one another, and of Bundler for explicitly managing Gem versions. So let's get them set up:

jmax@deepthought $ rvm use ruby-2.6.5@ruby-explorer --ruby-version --create
ruby-2.6.5 - #gemset created /home/jmax/.rvm/gems/ruby-2.6.5@ruby-explorer
ruby-2.6.5 - #generating ruby-explorer wrappers.........
Using /home/jmax/.rvm/gems/ruby-2.6.5 with gemset ruby-explorer
jmax@deepthought ~/projects/ruby-explorer $ gem install bundler
Fetching bundler-2.1.4.gem
Successfully installed bundler-2.1.4
Parsing documentation for bundler-2.1.4
Installing ri documentation for bundler-2.1.4
Done installing documentation for bundler after 4 seconds
1 gem installed
jmax@deepthought $ bundle init
Writing new Gemfile to /home/jmax/projects/ruby-explorer/Gemfile
jmax@deepthought $

So that's done. But this immediately brings up another issue. Lots of Rails projects are also going to be using rvm (or an rvm-compatible Ruby management package), and essentially all Rails projects are going to be using Bundler. We need to not interfere with either of those. So that goes on the Notes page.

RSpec: Testing the Code

I strongly prefer to code in a test-driven fashion, writing the tests before the code (spikes are an exception, and that's one of the reasons for my "spikes are not and will never be production code" rule). My testing framework of choice is RSpec, so installing that's next:

jmax@deepthought $ gem install rspec
Fetching rspec-expectations-3.9.1.gem
Fetching rspec-3.9.0.gem
Fetching rspec-mocks-3.9.1.gem
Fetching rspec-support-3.9.2.gem
Fetching rspec-core-3.9.1.gem
Fetching diff-lcs-1.3.gem
Successfully installed rspec-support-3.9.2
Successfully installed rspec-core-3.9.1
Successfully installed diff-lcs-1.3
Successfully installed rspec-expectations-3.9.1
Successfully installed rspec-mocks-3.9.1
Successfully installed rspec-3.9.0
Parsing documentation for rspec-support-3.9.2
Installing ri documentation for rspec-support-3.9.2
Parsing documentation for rspec-core-3.9.1
Installing ri documentation for rspec-core-3.9.1
Parsing documentation for diff-lcs-1.3
Couldn't find file to include 'Contributing.rdoc' from README.rdoc
Couldn't find file to include 'License.rdoc' from README.rdoc
Installing ri documentation for diff-lcs-1.3
Parsing documentation for rspec-expectations-3.9.1
Installing ri documentation for rspec-expectations-3.9.1
Parsing documentation for rspec-mocks-3.9.1
Installing ri documentation for rspec-mocks-3.9.1
Parsing documentation for rspec-3.9.0
Installing ri documentation for rspec-3.9.0
Done installing documentation for rspec-support, rspec-core, diff-lcs, rspec-expectations, rspec-mocks, rspec after 5 seconds
6 gems installed
jmax@deepthought $ rspec --init
Resolving dependencies...
  create   .rspec
  create   spec/spec_helper.rb
jmax@deepthought $

Starting the User Interface: The CLI

As an architecture for command-line applications in general, it's best to have the top-level CLI wrapper (the program that the user will actually invoke) just handle the command line arguments and options, then create and configure an application object and pass off control to it.

So, the first test is simply that the CLI creates an application object. I'm going to go through this first test in rather excruciating detail; I'll speed up the presentation after that, I promise.

describe "The CLI" do
  before do
    $the_app= nil
    load("bin/ruby-explorer")
  end

  it "creates the app" do
    expect($the_app).not_to be_nil
  end
end

We fake running the script by calling load. Ruby, in fact, when run on a file, does load the file. There's also some setup to deal with the command line, but since we're not worrying about the command line yet, so the test doesn't bother with faking any of it. We'll have to deal with that in a few minutes, but for now, we can ignore the issue. Running the test, we get:

jmax@deepthought $ rspec
Resolving dependencies...
F

Failures:

  1) The CLI creates the app
     Failure/Error: load("bin/ruby-explorer")

     LoadError:
       cannot load such file -- bin/ruby-explorer
     # ./spec/cli/cli_spec.rb:4:in `load'
     # ./spec/cli/cli_spec.rb:4:in `block (2 levels) in <top (required)>'

Finished in 0.00263 seconds (files took 0.11191 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/cli/cli_spec.rb:7 # The CLI creates the app

jmax@deepthought $

The test fails. Perfectly reasonable, since we're trying to load a file that we haven't created yet. So, we create the file:

#!/bin/env ruby

require_relative "../src/ruby_explorer.rb"

$the_app= RubyExplorer.new

Running the test again:

jmax@deepthought $ rspec
Resolving dependencies...
F

Failures:

  1) The CLI creates the app
     Failure/Error: load("bin/ruby-explorer")

     LoadError:
       cannot load such file -- /home/jmax/projects/ruby-explorer/src/ruby_explorer.rb
     # ./spec/cli/cli_spec.rb:4:in `load'
     # ./spec/cli/cli_spec.rb:4:in `block (2 levels) in <top (required)>'

Finished in 0.00307 seconds (files took 0.1151 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/cli/cli_spec.rb:7 # The CLI creates the app

jmax@deepthought $

Progress! The test still fails. But the important point is that it fails differently. Before, we were failing because the CLI didn't exist. Now, we're failing because the app class doesn't exist.

The next thing to do, obviously, is create the app class, in src/ruby_explorer.rb:

class RubyExplorer
end

And our first test passes. Before leaving it, there's one more thing to check. A secondary purpose of the tests is to provide documentation, so before considering a test finished, let's like to run the test suite in verbose mode, to ensure that the test description is sensible. Running rspec -fd, we get:

jmax@deepthought $ rspec -fd
Resolving dependencies...

The CLI
  creates the app

Finished in 0.00265 seconds (files took 0.11782 seconds to load)
1 example, 0 failures

jmax@deepthought $

That looks pretty good, so this step is complete

There's a bunch more fiddly bits involved in testing the CLI, which I'm going to skip over here, because they aren't directly related to the topic at hand (snooping on a Rails app). The details of the CLI testing are interesting enough to warrant a detailed post, just not here. I'm adding that to my list of topics on deck. Watch that space if you want details.

When all was said and done, the stub app class ended up like this:

class RubyExplorer
  attr_accessor :target_directory

  def run
  end
end

With this for the CLI itself:

#!/bin/env ruby

require 'optparse'

require_relative '../src/ruby_explorer.rb'


optparser= OptionParser.new do |opts|
  opts.banner= "usage: ruby-explorer [options] project-directory"
  opts.separator ""

  opts.on("-h", "--help", "Show this message") do
    $stderr.puts opts
    exit false
  end
end

optparser.parse!

if ARGV.length != 1
  $stderr.puts optparser
  exit false
end

$the_app= RubyExplorer.new
$the_app.target_directory= ARGV[0]
$the_app.run

exit true

The CLI checks the command line for syntactic validity; i.e. does the command line contain valid options and only one target directory. (Right now, there's only one valid option, -h (or --help), but this will likely change.) Assuming that the command line is syntactically valid, the CLI creates the application object, sets its target_directory property, and calls its run method. Semantic validity checking (checking whether the target directory actually exists, is a Rails project, and so on) is the application object's responsibility.

The CLI shouldn't change much during subsequent development. Changes should be just adding new options and passing their values through to the application object.

If we do decide to eventually create a really sophisticated CLI, then it would be best to use something like the thor gem, which handles most of the boilerplate for such things. The other plausible paths that we might take are to create a GUI, or a web front end. Right now, there's no need to get too fancy, and whatever we do later, having most of the logic outside of the actual CLI will help keep things decoupled enough to make upgrading the user interface fairly straightforward.

The test code for the CLI was actually the most complicated part:

require './src/ruby_explorer'

describe "The CLI" do
  def set_argv(new_value)
    Object.__send__(:remove_const, :ARGV)
    Object.const_set(:ARGV, new_value)
  end

  def invoke_cli_with(argv)
    $the_app= nil
    @error_exit= nil
    @stderr_output= nil
    @saved_stderr= $stderr
    $stderr= StringIO.new

    set_argv(argv)
    begin
      load("bin/ruby-explorer")
    rescue SystemExit => e
      @error_exit= !e.success?
    end
    @stderr_output= $stderr.string
    $stderr= @saved_stderr
  end

  def self.expect_cli_to_fail_with(argv)
    before do
      invoke_cli_with(argv)
    end

    it "does not create the app" do
      expect($the_app).to be_nil
    end

    it "prints a usage message to stderr" do
      expect(@stderr_output).to match(/usage:/)
    end

    it "exits with an error status" do
      expect(@error_exit).to be(true)
    end
  end

  def self.expect_cli_to_succeed_with(argv)
    before do
      invoke_cli_with(argv)
    end

    it "creates the app" do
      expect($the_app).not_to be_nil
    end

    it "does not print anything to stderr" do
      expect(@stderr_output).to eq("")
    end

    it "exits with an success status" do
      expect(@error_exit).to be(false)
    end

    yield if block_given?
  end


  before do
    allow_any_instance_of(RubyExplorer).to receive(:run)
  end

  describe "with no arguments" do
    expect_cli_to_fail_with []
  end

  describe "with more than one argument" do
    expect_cli_to_fail_with ["one-option", "another-option"]
  end

  describe "with a short help option" do
    expect_cli_to_fail_with ["-h"]
  end

  describe "with a long help option" do
    expect_cli_to_fail_with ["--help"]
  end

  describe "with a single argument" do
    expect_cli_to_succeed_with ["not-an-option"] do
      it "sets the app's target directory" do
        expect($the_app.target_directory).to eq("not-an-option")
      end

      it "calls the app's run method" do
        expect($the_app).to have_received(:run)
      end
    end
  end
end
< Prev^ UpNext >