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!
Responses