Episode #063: Gem-Love Part 3

Upgrade to download episode video.

Episode Script

When I last left the gem-love project, I had a failing acceptance test and a skeleton command object with no implementation. Today I'm going to start on that implementation.

Here's the #execute method as it stands now:

def execute
  puts "Under construction..."
end

I think for a little while about what code I want to see in here. Since this is a class that adapts my code into the RubyGems command framework, I don't want to see detailed, low-level code in here. I just want to see some very high-level, coarse-grained code that acts as an entry point into the code that's fully mine to control.

One things for sure, I'll need to pull a gem name from the command's arguments in order to determine which gem is being loved. So I assign a variable for it, using the get_one_gem_name method provided by RubyGems. I know about this method from poking around in the source code of some of the standard RubyGems commands earlier.

Now what? I think about what message I want to send in order to get the ball rolling, and the answer comes back pretty quickly: gem-love is all about people endorsing gems, so the message I want to send here is #endorse_gem, with the gem_name as an argument.

def execute
  gem_name = get_one_gem_name
  .endorse_gem(gem_name)
end

The next question is: what role makes sense to receive the #endorse_gem message? gem_user seems to fit the bill here, since it's the users of gems who will be making endorsements.

def execute
  gem_name = get_one_gem_name
  gem_user.endorse_gem(gem_name)
end

OK, now where will I get an object to play the role of gem_user? The obvious answer seems to be to instantiate one right here. I re-use the name of the role in naming the class that I instantiate, and I namespace inside the as-yet-nonexistent GemLove module.

def execute
  gem_name = get_one_gem_name
  gem_user = GemLove::GemUser.new
  gem_user.endorse_gem(gem_name)
end

By the way, if you're unfamiliar with this approach of starting with a message and working backwards, I highly recommend reading Sandi Metz' book, “Practical Object Oriented Development in Ruby”.

Running the single test shows that I've taken a step back: instead of a failure, I now have an error because of the unrecognized class name.

$ rspec
F

Failures:

  1) gem love command endorsing a gem
     Failure/Error: command.invoke(*args)
     NameError:
       uninitialized constant GemLove::GemUser
     # ./spec/acceptance_spec.rb:50:in `run'
     # ./spec/acceptance_spec.rb:43:in `block (2 levels) in <top (required)>'

Finished in 0.04126 seconds
1 example, 1 failure

I proceed to add the GemLove namespace module and GemUser class directly to the love_command.rb file. I don't think they'll live here forever, but as I'm feeling out the design I don't want to commit to a file hierarchy just yet.

module GemLove
  class GemUser
  end
end

I run the test again, and it complains about a missing method this time.

$ rspec
F

Failures:

  1) gem love command endorsing a gem
     Failure/Error: command.invoke(*args)
     NoMethodError:
       undefined method `endorse_gem' for #<GemLove::GemUser:0x00000001755070>
     # ./spec/acceptance_spec.rb:50:in `run'
     # ./spec/acceptance_spec.rb:43:in `block (2 levels) in <top (required)>'

Finished in 0.04367 seconds
1 example, 1 failure

Adding an empty method gets me back to my original test failure.

module GemLove
  class GemUser
    def endorse_gem(gem_name)
    end
  end
end
$ rspec
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:44:in `block (2 levels) in <top (required)>'

Finished in 0.06332 seconds
1 example, 1 failure

This is where I want to be, since this is a test that should only go green once the command is working on both the client and server side.

Now, what should this #endorse_gem method do? To answer that question, I make a new spec file:

require_relative '../../lib/rubygems/commands/love_command.rb'

module GemLove
  describe GemUser do
  end
end

I also need to add a require to the love_command.rb file in order to make it loadable in isolation.

require 'rubygems/command'

Now I write an example for the #endorse_gem method. This is going to be an integration test—that is, it will test how the method interacts with system code. Specifically, it will test that it makes the appropriate HTTP request. In order to verify this, I require a library called “WebMock”. WebMock worms its way into HTTP libraries and makes it easy to fake out HTTP requests.

The test begins by instantiating a GemUser. Then it prepares for a faked server interaction by telling WebMock to stub out any requests to www.gemlove.org/endorsements/fattr. fattr is the name of the gem I'll be making a test endorsement of.

It then tells the gem_user object to endorse the gem. Finally, it asserts that an HTTP POST was made to the URL that was stubbed earlier.

require_relative '../../lib/rubygems/commands/love_command.rb'
require 'webmock/rspec'

module GemLove
  describe GemUser do
    describe '#endorse_gem' do
      it 'registers a gem endorsement with the gem-love server' do
        gem_user = GemUser.new
        stub_request(:any, 'www.gemlove.org/endorsements/fattr')
        gem_user.endorse_gem("fattr")
        a_request(:post, 'www.gemlove.org/endorsements/fattr').
          should have_been_made
      end
    end
  end
end

When I run the test, it fails, letting me know that the expected POST never came.

$ rspec spec/gem_love/gem_user_spec.rb 
F

Failures:

  1) GemLove::GemUser#endorse_gem registers a gem endorsement with the gem-love server
     Failure/Error: a_request(:post, 'www.gemlove.org/endorsements/fattr').
       The request POST http://www.gemlove.org/endorsements/fattr was expected to execute 1 time but it executed 0 tim
es                                                                                                                   
       
       The following requests were made:
       
       No requests were made.
       ============================================================
     # ./spec/gem_love/gem_user_spec.rb:11:in `block (3 levels) in <module:GemLove>'

Finished in 0.00941 seconds
1 example, 1 failure

I now switch over to the code under test. I add a require for Net::HTTP, and then fill in the blank method. It will construct a URL using the given gem_name, and then POST to that URL.

require 'net/http'
module GemLove
  class GemUser
    def endorse_gem(gem_name)
      url = URI("http://www.gemlove.org/endorsements/#{gem_name}")
      Net::HTTP.post_form(url, {})
    end
  end
end

Running the test again, it passes!

$ rspec spec/gem_love/gem_user_spec.rb 
.

Finished in 0.00204 seconds
1 example, 0 failures

When I run all specs, however, I get failures.

$ rspec
FF

Failures:

  1) gem love command endorsing a gem
     Failure/Error: command.invoke(*args)
     WebMock::NetConnectNotAllowedError:
       Real HTTP connections are disabled. Unregistered request: POST http://www.gemlove.org/endorsements/fattr with h
eaders {'Accept'=>'*/*', 'Content-Type'=>'application/x-www-form-urlencoded', 'User-Agent'=>'Ruby'}                  
       
       You can stub this request with the following snippet:
       
       stub_request(:post, "http://www.gemlove.org/endorsements/fattr").
         with(:headers => {'Accept'=>'*/*', 'Content-Type'=>'application/x-www-form-urlencoded', 'User-Agent'=>'Ruby'}
).                                                                                                                   
         to_return(:status => 200, :body => "", :headers => {})
       
       ============================================================
     # ./spec/acceptance_spec.rb:50:in `run'
     # ./spec/acceptance_spec.rb:43:in `block (2 levels) in <top (required)>'

  2) GemLove::GemUser#endorse_gem registers a gem endorsement with the gem-love server
     Failure/Error: a_request(:post, 'www.gemlove.org/endorsements/fattr').
       The request POST http://www.gemlove.org/endorsements/fattr was expected to execute 1 time but it executed 2 tim
es                                                                                                                   
       
       The following requests were made:
       
       POST http://www.gemlove.org/endorsements/fattr with headers {'Accept'=>'*/*', 'Content-Type'=>'application/x-ww
w-form-urlencoded', 'User-Agent'=>'Ruby'} was made 2 times                                                           
       
       ============================================================
     # ./spec/gem_love/gem_user_spec.rb:11:in `block (3 levels) in <module:GemLove>'

Finished in 0.04752 seconds
2 examples, 2 failures

This is to be expected. Now that the LoveCommand actually tries to make an HTTP request, the acceptance test is going to fail until it has a server to talk to. For the time being I just pend out the acceptance test.

specify 'endorsing a gem' do
  pending "completion of the server side"
  run 'gem love fattr'
  gem_named('fattr').should have(1).endorsements
end

Now I have one pending spec and one passing one:

$ rspec
*.

Pending:
  gem love command endorsing a gem
    # completion of the server side
    # ./spec/acceptance_spec.rb:42

Finished in 0.05101 seconds
2 examples, 0 failures, 1 pending

I now have a first cut at a client-side implementation. It is obviously incomplete – for instance, I haven't begun to think about identifying who the gem user is, let alone authenticating them with some kind of server-side account. But I want to get a “walking skeleton” working end-to-end before I begin to flesh out more functionality.

That's enough for now. Happy hacking!