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