Josh Rendek

<3 Ruby

Writing Dependable Ruby & a Reddit CLI

View Source on Github

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.

spec/spec_helper.rb
1
2
3
4
5
6
7
require 'rspec'
require 'vcr'
require 'pry'
VCR.configure do |c|
  c.cassette_library_dir = 'fixtures/vcr_cassettes'
  c.hook_into :fakeweb# or :fakeweb
end

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

Reddit API Spec (Pass 1) - spec/lib/reddit_api_spec
1
2
3
4
5
6
7
8
9
10
11
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

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.

Reddit API (Pass 1) - lib/reddit_api.rb
1
2
3
4
5
6
7
8
9
10
11
12
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

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

Reddit API Spec (Pass 2) - spec/lib/reddit_api_spec
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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

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.

Story - lib/story.rb
1
Story = Struct.new(:title, :score, :comments, :url)
Reddit API (Pass 2) - lib/reddit_api.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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

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:

Reddit API Spec (Pass 3) - spec/lib/reddit_api_spec
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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

And let’s make the test pass:

Reddit API (Pass 3) - lib/reddit_api.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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

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:

bad_example.rb
1
2
3
@reddit = Reddit.new('ProgrammerHumor')
# User presses next 
@reddit.url = "http://reddit.com/r/ProgrammerHumor/.json?after=sometoken"

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:

Reddit CLI Spec (Pass 1) - spec/lib/reddit-cli.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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

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:

Reddit CLI (Pass 1) - lib/reddit-cli.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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

And finally, a little wrapper in the root directory:

Wrapper - reddit-cli.rb
1
2
3
4
5
require './lib/reddit_api'
require './lib/reddit-cli'

subreddit = ARGV[0]
RedditCli.new(RedditApi.new(subreddit))

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

View Source on Github

Comments