In Progress
Unit 1, Lesson 21
In Progress

Gem-Love Part 4

Video transcript & code

Previously in this miniseries on my Gem-Love project, I made a first stab at a client-side implementation for endorsing a gem. Today, I want to complete the server side of the equation.

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

I begin by starting a spec file for a Server class. It requires the file gem_love/server, which doesn't exist yet. It also requires rack/test, because the server is going to be a Rack-compatible HTTP application. Next comes a skeleton for the tests to come.

require_relative '../../lib/gem_love/server'
require 'rack/test'

module GemLove
  describe Server do
  end
end

Next I create the gem_love/server file. In it I define a Server class inside the GemLove module.

module GemLove
  class Server
  end
end

I want to stop here briefly and do some disclosure. In this series I've tried to show my TDD cycle for building code. But the truth is, I do sometimes omit steps in the interest of time. For instance, I would ordinarily run the spec file I created before creating the implementation file—to verify, first, that I got a missing file exception, and then, once the file existed, that I got a missing constant error for the Server class. The faster I can get a failure, the better, because that keeps me grounded in the reality of my code.

So if in this or future episodes you see me skip directly from writing a test to writing implementation without the intermediate step of running the test and seeing it fail, that's to keep the pace of the demonstration moving forward. When I'm not recording a 5-minute screencast I run the tests as often as possible.

Back to the task at hand. When my Server receives an endorsement request, I expect it to record the new gem endorsement in a list somewhere. That suggests that for this test I need something to play the role of that endorsement list, so I create a test double for the job. Then I instantiate a server, injecting the endorsement list double as an option.

Next I create a fake browser session, using tools provided by Rack and Rack::Test. I pass the server object into the fake browser. Then I make an assertion that, once the request is complete, the endorsement_list should have received a message telling it to add a new entry. Finally, I tell the fake browser to make a POST request endorsing Ara T. Howard's fattr gem.

module GemLove
  describe Server do
    describe "receiving a gem endorsement" do
      it "records a new endorsement for the given gem" do
        endorsement_list = double
        server  = GemLove::Server.new(endorsement_list: endorsement_list)
        browser = Rack::Test::Session.new(Rack::MockSession.new(server))

        endorsement_list.should_receive(:add_endorsement_for_gem).with('fattr')
        browser.post('/endorsements/fattr')
      end
    end
  end
end

To make this test pass, I first require the 'sinatra' gem in the implementation file. While I could write the server as a straight Rack app, Sinatra adds a lot of handy conveniences which will speed up the process. I make the Server class a subclass of Sinatra::Base, and give it an initializer. The initializer sets an @endorsement_list instance variable based on the passed-in option of the same name.

Then use Sinatra's DSL for setting up HTTP routes to define a POST action at /endorsements/:gem_name, where :gem_name can be any string. The body of this action is a single line which tells the @endorsement_list to add a new record, using the given gem_name.

require 'sinatra/base'

module GemLove
  class Server < Sinatra::Base
    def initialize(options={})
      super()
      @endorsement_list = options.fetch(:endorsement_list)
    end

    post '/endorsements/:gem_name' do
      @endorsement_list.add_endorsement_for_gem(params[:gem_name])
    end
  end
end

This satisfies the test. Now that I'm confident it works, I go back and add a default implementation for the @endorsement_list for when I'm not explicitly injecting one in. I decide the Endorsement class, which you may recall is a DataMapper-based model, makes sense for the role of endorsement_list.

@endorsement_list = options.fetch(:endorsement_list) { Endorsement }

Now that I've identified which object will play this role, I need to give it the ability to respond to the #add_endorsement_for_gem message. So I create a spec file for the Endorsement class. It requires the implementation file, then proceeds to describe the desired behavior for this class-level method.

The method being described here is a "borderland" method—that is, is the last stop in my application code before control will be turned over to the database, with DataMapper as an intermediary. Since this method will adapt my code to code outside my control, I test it in an integration style. I use RSpec's expect to set up a block which will exercise the method. Then I assert that after the block is executed, the endorsements table has one more endorsement in it.

require_relative '../../lib/gem_love/endorsement'

module GemLove
  describe Endorsement do
    describe '.add_endorsement_for_gem' do
      it 'records a new endorsement' do
        expect {
          Endorsement.add_endorsement_for_gem('mygem')
        }.to change{Endorsement.all_for_gem_named('mygem')}.by(1)
      end
    end
  end
end

There are several steps to making this work. First, I need to move the Endorsement class out of its temporary home in the acceptance spec file, and into a file of its own. Then when I run the test, the error I see is one complaining that the DataMapper repository is not yet set up.

To fix this I grab the database setup and migration code that was being run for the acceptance test, and move it into an RSpec configuration block in a new spec_helper.rb file. In the "before each" configuration I add a db: true tag. This tells RSpec to only use this before hook on examples or spec suites which are also tagged with db: true.

1) GemLove::Endorsement.add_endorsement_for_gem records a new endorsement
   Failure/Error: }.to change{Endorsement.all_for_gem_named('mygem')}.by(1)
   DataMapper::RepositoryNotSetupError:
     Adapter not set: default. Did you forget to setup?
   # ./lib/gem_love/endorsement.rb:9:in `all_for_gem_named'
RSpec.configure do |config|
  config.before(:each, db: true) do
    DataMapper.setup(:default, 'sqlite::memory:')
    DataMapper.auto_migrate!
  end
end

Back in the endorsement_spec.rb, I require the new spec_helper file, and tag the example with db: true.

require 'spec_helper'
require_relative '../../lib/gem_love/endorsement'

module GemLove
  describe Endorsement do
    describe '.add_endorsement_for_gem', db: true  do
      it 'records a new endorsement' do
        expect {
          Endorsement.add_endorsement_for_gem('mygem')
        }.to change{Endorsement.all_for_gem_named('mygem')}.by(1)
      end
    end
  end
end

Now I'm finally ready to add the .add_endorsement_for_gem method. The implementation is anticlimactic: it simply delegates to the DataMapper .create method to save a new row in the database.

class Endorsement
  # ...
  def self.add_endorsement_for_gem(gem_name)
    create(gem_name: gem_name)
  end
end

With that addition made, (and a quick fix to the test to test the count of endorsements instead of the endorsements themselves), and the test passing, I've now completed the server side of adding endorsements. In the next edition of this miniseries I'll verify that the client and server can work with each other. But for now I'll say goodbye, and, as always: happy hacking!

Responses