{ Josh Rendek }

<3 Go & Kubernetes

Writing Dependable Ruby & a Reddit CLI

Aug 20, 2012 - 7 minutes

When you work on your code and are finished for the day, is what you have committed worry free? If another developer were to push your code in the middle of the night, would they be calling you at 3am?

Let’s see how we can improve our development cycle with testing so we can avoid those early morning calls. We’ll go over some of the basics with a simple project to start.

The most important part about TDD is getting quick feedback based on our desired design (the feedback loop).

Here is an example of how fast the tests run:

While this is a somewhat contrived example for the reddit cli we’re making, this can be applied equally as well when writing Rails applications. Only load the parts you need (ActionMailer, ActiveSupport, etc), usually you don’t need to load the entire rails stack. This can make your tests run in milliseconds instead of seconds. This lets you get feedback right away.

Before we go further into the testing discussion, lets setup a spec helper.

{% codeblock spec/spec_helper.rb lang:ruby %} require ‘rspec’ require ‘vcr’ require ‘pry’ VCR.configure do |c| c.cassette_library_dir = ‘fixtures/vcr_cassettes’ c.hook_into :fakeweb# or :fakeweb end {% endcodeblock %}

Now how do we start doing TDD? We first start with a failing test.

{% codeblock Reddit API Spec (Pass 1) - spec/lib/reddit_api_spec lang:ruby %} require ‘spec_helper’ require ‘./lib/reddit_api’

describe RedditApi do let(:reddit) { RedditApi.new(‘ProgrammerHumor’) } context “#initializing” do it “should form the correct endpoint” do reddit.url.should eq “http://reddit.com/r/ProgrammerHumor/.json?after=" end end end {% endcodeblock %}

When we create a new instance of the Reddit API we want to pass it a subreddit, and then we want to make sure it builds the URL properly.

{% codeblock Reddit API (Pass 1) - lib/reddit_api.rb lang:ruby %} require ‘json’ require ‘rest-client’

class RedditApi REDDIT_URL = “http://reddit.com/r/" attr_reader :url, :stories def initialize(subreddit) @subreddit = subreddit @after = "” @url = “#{REDDIT_URL}#{subreddit}/.json?after=#{@after}” end end {% endcodeblock %}

Next we want to make the actual HTTP request to the Reddit api and process it.

{% codeblock Reddit API Spec (Pass 2) - spec/lib/reddit_api_spec lang:ruby %} require ‘spec_helper’ require ‘./lib/reddit_api’

describe RedditApi do let(:reddit) { RedditApi.new(‘ProgrammerHumor’) } context “#initializing” do it “should form the correct endpoint” do VCR.use_cassette(‘reddit_programmer_humor’) do reddit.url.should eq “http://reddit.com/r/ProgrammerHumor/.json?after=" end end end

context "#fetching" do
    it "should fetch the first page of stories" do
        VCR.use_cassette('reddit_programmer_humor') do
            reddit.stories.count.should eq(25)
        end
    end
end

end {% endcodeblock %}

We’ve now added a VCR wrapper and added an expectation that the reddit api will return a list of stories. We use VCR here to again ensure that our tests run fast. Once we make the first request, future runs will take milliseconds and will hit our VCR tape instead of the API.

Now we need to introduce three new areas: requesting, processing, and a Story object class.

{% codeblock Story - lib/story.rb lang:ruby %} Story = Struct.new(:title, :score, :comments, :url) {% endcodeblock %}

{% codeblock Reddit API (Pass 2) - lib/reddit_api.rb lang:ruby %} require ‘json’ require ‘rest-client’ require ‘./lib/story’

class RedditApi REDDIT_URL = “http://reddit.com/r/" attr_reader :url, :stories def initialize(subreddit) @subreddit = subreddit @after = "” @url = “#{REDDIT_URL}#{subreddit}/.json?after=#{@after}” request process_request end

def request
    @request_response = JSON.parse(RestClient.get(@url))
end

def process_request
    @stories = []
    @request_response['data']['children'].each do |red|
        d = red['data']
        @stories << Story.new(d['title'], d['score'],
                              d['num_comments'], d['url'])
    end
    @after = @request_response['data']['after']
end

end {% endcodeblock %}

What can we do now? The API lets us make a full request and get a list of Story struct objects back. We’ll be using this array of structs later on to build the CLi.

The only thing left for this simple CLI a way to get to the next page. Let’s add our failing spec:

{% codeblock Reddit API Spec (Pass 3) - spec/lib/reddit_api_spec lang:ruby %} require ‘spec_helper’ require ‘./lib/reddit_api’

describe RedditApi do let(:reddit) { RedditApi.new(‘ProgrammerHumor’) } context “#initializing” do it “should form the correct endpoint” do VCR.use_cassette(‘reddit_programmer_humor’) do reddit.url.should eq “http://reddit.com/r/ProgrammerHumor/.json?after=" end end end

context "#fetching" do
    it "should fetch the first page of stories" do
        VCR.use_cassette('reddit_programmer_humor') do
            reddit.stories.count.should eq(25)
        end
    end

    it "should fetch the second page of stories" do
        VCR.use_cassette('reddit_programmer_humor_p2') do
            reddit.next.stories.count.should eq(25)
        end
    end
end

end {% endcodeblock %}

And let’s make the test pass:

{% codeblock Reddit API (Pass 3) - lib/reddit_api.rb lang:ruby %} require ‘json’ require ‘rest-client’ require ‘./lib/story’

class RedditApi REDDIT_URL = “http://reddit.com/r/" attr_reader :url, :stories def initialize(subreddit) @subreddit = subreddit @after = "” @url = “#{REDDIT_URL}#{subreddit}/.json?after=#{@after}” request process_request end

def next
    @url = "#{REDDIT_URL}#{@subreddit}/.json?after=#{@after}"
    request
    process_request
    self
end

def request
    @request_response = JSON.parse(RestClient.get(@url))
end

def process_request
    @stories = []
    @request_response['data']['children'].each do |red|
        d = red['data']
        @stories << Story.new(d['title'], d['score'],
                              d['num_comments'], d['url'])
    end
    @after = @request_response['data']['after']
end

end {% endcodeblock %}

We also allow method chaining since we return self after calling next (so you could chain next’s for instance).

Another important principal to keep in mind is the “Tell, Dont Ask” rule. Without tests, we might have gone this route:

{% codeblock bad_example.rb lang:ruby %} @reddit = Reddit.new(‘ProgrammerHumor’)

User presses next

@reddit.url = “http://reddit.com/r/ProgrammerHumor/.json?after=sometoken" {% endcodeblock %}

Not only would we not be telling the object what we want, we would be modifying the internal state of an object as well. By implementing a next method we abstract the idea of a URL and any tokens we may need to keep track of away from the consumer. Doing TDD adds a little extra step of “Thinking” more about what we want our interfaces to be. What’s easier? Calling next or modifying the internal state?

I’m kind of cheating a bit here. I found a nice “table” gem that outputs what you send in as a formatted table (think MySQL console output). Let’s just make sure everything is being sent around properly and STDOUT is printing the correct contents:

{% codeblock Reddit CLI Spec (Pass 1) - spec/lib/reddit-cli.rb lang:ruby %} require ‘spec_helper’ require ‘stringio’ require ‘./lib/reddit-cli’

describe RedditCli do let(:subreddit) { “ProgrammerHumor” } context “#initializing” do before(:all) do $stdout = @fakeout = StringIO.new end

    it "should print out a story" do
        api_response = double(RedditApi)
        api_response.stub!(:stories =>
                           [Story.new("StoryTitle", "Score",
                                      "Comments", "URL")])
        $stdin.should_receive(:gets).and_return("q")
        cli = RedditCli.new(api_response)
        $stdout = STDOUT
        @fakeout.string.include?('StoryTitle').should be_true
    end
end

end {% endcodeblock %}

We’re doing several things here. First we’re taking $stdout and putting it (temporarily) into a instance variable so we can see what gets outputted. Next we’re mocking out the RedditApi since we dont actually need to hit that class or the VCR tapes, we just need to stub out the expected results (stories) and pass the response object along to the CLI class. And finally once we’re finished we set $stdout back to the proper constant.

And the class for output:

{% codeblock Reddit CLI (Pass 1) - lib/reddit-cli.rb lang:ruby %} require ‘./lib/reddit_api’ require ’terminal-table’ class RedditCli def initialize(api) @rows = [] @api = api @stories = api.stories print_stories print “\nType ? for help\n” prompt end

def print_stories
    @stories.each_with_index {|x, i| @rows << [i, x.score, x.comments, x.title[0..79] ] }
    puts Terminal::Table.new :headings=> ['#', 'Score', 'Comments', 'Title'], :rows => @rows
end

def prompt
    print "\n?> "
    input = STDIN.gets.chomp
    case input
    when "?"
        p "Type the # of a story to open it in your browser"
        p "Type n to go to the next page"
        prompt
    when "quit", "q"
    when "n"
        @rows = []
        @stories = @api.next.stories
        print_stories
        prompt
    else
        print "#=> Oepning: #{@stories[input.to_i].url}"
        `open #{@stories[input.to_i].url}`
        prompt
    end
end

end {% endcodeblock %}

And finally, a little wrapper in the root directory:

{% codeblock Wrapper - reddit-cli.rb lang:ruby %} require ‘./lib/reddit_api’ require ‘./lib/reddit-cli’

subreddit = ARGV[0] RedditCli.new(RedditApi.new(subreddit)) {% endcodeblock %}

An Important Note

When working with external resources, whether it be a gem or a remote API, it’s important to wrap those endpoints in your own abstraction. For instance, with our Reddit CLI we could have avoided those first 2 classes entirely, written everything in the CLI display class, and worked with the raw JSON. But what happens when Reddit changes their API? If this CLI class was huge or incoporated many other components, this could be quite a big code change. Instead, what we wrote encapsulates the API inside a RedditApi class that returns a generic Story struct we can work with and pass around. We don’t care if the API changes in the CLI, or in any other code. If the API changes, we only have to update the one API class to mold the new API to the output we were already generating.

End Result & Source Code

comments powered by Disqus