Episode #046: Gem Love Part 2

Upgrade to download episode video.

Episode Script

In part one of this series I did a code spike to learn how to write a Rubygems plugin. Now it's time to settle into the rhythm of the behavior-driven design cycle, and start cranking the first feature out. I start with an acceptance test, written in RSpec syntax.

describe 'gem love command' do
  specify 'endorsing a gem' do
    run 'gem love fattr'
    gem_named('fattr').should have(1).endorsements
  end
end

This test describes, in high-level language, what the app does. The single example describes a user story: when I run the command ‘gem love fattr', then the “fattr” gem should have an endorsement associated with it.

You might be wondering what some of these methods are. What is run, or gem_named? Where were they defined? The answer is that they aren't defined, not yet. When writing an acceptance test, it's important to keep the language very high level. I don't want the story this example describes getting lost in implementation details about running commands or looking up gems. So I start by writing a test using helper methods that tell the story clearly. My next task will be to define those methods.

Just to check that this is, in fact, my next task, I run rspec. As expected, it complains about a missing run method.

petronius% rspec
F

Failures:

  1) gem love command endorsing a gem
     Failure/Error: run 'gem love fattr'
     NoMethodError:
       undefined method `run' for #<RSpec::Core::ExampleGroup::Nested_1:0x0000000443a888>
     # ./spec/acceptance_spec.rb:3:in `block (2 levels) in <top (required)>'

Finished in 0.00035 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/acceptance_spec.rb:2 # gem love command endorsing a gem

I define the run helper method inside the RSpec describe block. Despite the fact that I already researched how to test gem plugins from the command line in the last episode, I've decided that I'm going to simplify this first cut by cheating a bit. Rather than actually running the gem command in a subprocess, I'm going to instantiate an instance of my LoveCommand object and execute it in-process. In order to do that, I first take the passed command and trim off the ‘gem love' part, then break it up into individual arguments using #shellsplit, which I get from the shellwords standard library. Then I instantiate the LoveCommand class and invoke the instance on the split-up arguments.

When I eventually decide to start testing this gem end-to-end using a real shell command I'll just swap out this implementation of run with one that starts a subprocess.

require 'shellwords'

describe 'gem love command' do
  specify 'endorsing a gem' do
    run 'gem love fattr'
    gem_named('fattr').should have(1).endorsements
  end

  def run(shell_command)
    args = shell_command.sub(/^gem love /, '').shellsplit
    command = Gem::Commands::LoveCommand.new
    command.invoke(*args)
  end
end

I run rspec again. This time it fails because it can't find the Gem::Commands module.

petronius% rspec
F

Failures:

  1) gem love command endorsing a gem
     Failure/Error: command = Gem::Commands::LoveCommand.new
     NameError:
       uninitialized constant Gem::Commands
     # ./spec/acceptance_spec.rb:11:in `run'
     # ./spec/acceptance_spec.rb:5:in `block (2 levels) in <top (required)>'

Finished in 0.00037 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/acceptance_spec.rb:4 # gem love command endorsing a gem

I go to the top of my acceptance test file and add the project's lib directory to Ruby's load path. Then I require an as-yet nonexistent file called gem_love.

$LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__))
require 'gem_love'
require 'shellwords'

Then I create the lib/gem_love.rb file, and fill it with the requires needed to load up both the LoveCommand itself, and the Rubygems::Command base class it depends on.

require 'rubygems/command'
require 'rubygems/commands/love_command'

Now when I run RSpec, it complains about a missing method called gem_named.

petronius% rspec
Under construction...
F

Failures:

  1) gem love command endorsing a gem
     Failure/Error: gem_named('fattr').should have(1).endorsements
     NoMethodError:
       undefined method `gem_named' for #<RSpec::Core::ExampleGroup::Nested_1:0x00000002d54df8>
     # ./spec/acceptance_spec.rb:8:in `block (2 levels) in <top (required)>'

Finished in 0.00098 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/acceptance_spec.rb:6 # gem love command endorsing a gem

Once again, I define the missing helper method inside my describe block. This time around, it just delegates to a similarly-named method on the GemLove module.

def gem_named(name)
  GemLove.gem_named(name)
end

Since I've referenced a module called GemLove, I should probably define it. For the time being I just define it right in the test file. I define the method gem_named inside it. The method asks a class named Rubygem to #get the named gem.

module GemLove
  def self.gem_named(name)
    Rubygem.get(name)
  end
end

When I run rspec again, it helpfully points out that the Rubygem class doesn't exist yet.

petronius% rspec
Under construction...
F

Failures:

  1) gem love command endorsing a gem
     Failure/Error: Rubygem.get(name)
     NameError:
       uninitialized constant Rubygem
     # ./spec/acceptance_spec.rb:18:in `gem_named'
     # ./spec/acceptance_spec.rb:8:in `block (2 levels) in <top (required)>'

Finished in 0.00121 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/acceptance_spec.rb:6 # gem love command endorsing a gem

I define the class and define the get class method to simply return a new instance of the class.

class Rubygem
  def self.get(name)
    new
  end
end

Now the problem that rspec reports is that there's no method called #endorsements on Rubygem objects.

petronius% rspec
Under construction...
F

Failures:

  1) gem love command endorsing a gem
     Failure/Error: gem_named('fattr').should have(1).endorsements
     NoMethodError:
       undefined method `endorsements' for #<GemLove::Rubygem:0x0000000384ae10>
     # ./spec/acceptance_spec.rb:20:in `block (2 levels) in <top (required)>'

Finished in 0.00156 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/acceptance_spec.rb:18 # gem love command endorsing a gem

So I add the #endorsements method to Rubygem. The method will ask an endorsement list for a subset of endorsements which apply to a given gem name. This means that I'll need to keep the gem name around. To make this happen, I switch the class to a Struct with just one attribute: name. Then I update the .get method to initialize the returned Rubygem object with the given gem name. Finally, I define what object will play the role of the endorsement_list. I choose an yet-to-be defined class named Endorsement for that job.

I've hidden this class behind the endorsement_list method in order to minimize hardcoded class dependencies in my objects. I can change the repository a Rubygem searches for endorsements by modifying just one method.

  Rubygem = Struct.new(:name) do
    def self.get(name)
      new(name)
    end

    def endorsements
      endorsement_list.all_for_gem_named(name)
    end

    def endorsement_list
      Endorsement
    end
  end
end

rspec now reminds me that I haven't actually written the Endorsement class.

petronius% rspec
Under construction...
F

Failures:

  1) gem love command endorsing a gem
     Failure/Error: Endorsement
     NameError:
       uninitialized constant GemLove::Endorsement
     # ./spec/acceptance_spec.rb:20:in `endorsement_list'
     # ./spec/acceptance_spec.rb:16:in `endorsements'
     # ./spec/acceptance_spec.rb:28:in `block (2 levels) in <top (required)>'

Finished in 0.00157 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/acceptance_spec.rb:26 # gem love command endorsing a gem

I know that I want to keep a persistent list of endorsements, so I require the DataMapper library to help me interface with a database. Then I define Endorsement. It includes DataMapper::Resource for persistence, and defines a single table column: the gem name. For now I use the gem name as the natural key for the endorsements table, although this will have to change as soon as I want more than one endorsement per gem.

Then I define the .all_for_gem_named method. It uses the all method provided by DataMapper to look up endorsements by gem name.

require 'data_mapper'
class Endorsement
  include DataMapper::Resource

  property :gem_name, String, key: true

  def self.all_for_gem_named(name)
    all(gem_name: name)
  end
end

I also add some DataMapper initialization to the test suite. Before any tests are run, it will first connect to an in-memory sqlite database, and then auto-migrate the database to have a schema consistent with any defined DataMapper model classes.

before :all do
  DataMapper.setup(:default, 'sqlite::memory:')
  DataMapper.auto_migrate!
end

I run rspec again, and for the first time I have a proper failure, rather than an error. It tells me that running the gem love command failed to generate an endorsement. This means that I have reached the point where if this failure goes away, it means I have finished the first feature.

petronius% rspec
Under construction...
F

Failures:

  1) gem love command endorsing a gem
     Failure/Error: gem_named('fattr').should have(1).endorsements
       expected 1 endorsements, got 0
     # ./spec/acceptance_spec.rb:47:in `block (2 levels) in <top (required)>'

Finished in 0.50624 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/acceptance_spec.rb:45 # gem love command endorsing a gem

And that's where I leave things for now. While it's true I haven't written any domain logic yet, I've accomplished a number of things:

  • I've gotten my testing infrastructure assembled and working
  • I've written a high-level, two-line executable specification for my first user story.
  • I've started to hash out the structure of my object model, guided by the test.
  • I've set up a persistence mechanism
  • Most importantly, I've established a test-driven rhythm for my application from the get-go. Every step from here until completion will be either making a test pass, refactoring the resulting code, or improving my test suite to force more implementation changes. And since I've left myself a failing test with clear failure message, I'll know right where to pick working when I come back to this project the next time.

That's all for today! Happy hacking!