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
1234567
require'rspec'require'vcr'require'pry'VCR.configuredo|c|c.cassette_library_dir='fixtures/vcr_cassettes'c.hook_into:fakeweb# or :fakewebend
Now how do we start doing TDD? We first start with a failing test.
Reddit API Spec (Pass 1) - spec/lib/reddit_api_spec
1234567891011
require'spec_helper'require'./lib/reddit_api'describeRedditApidolet(:reddit){RedditApi.new('ProgrammerHumor')}context"#initializing"doit"should form the correct endpoint"doreddit.url.shouldeq"http://reddit.com/r/ProgrammerHumor/.json?after="endendend
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.
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
123456789101112131415161718192021
require'spec_helper'require'./lib/reddit_api'describeRedditApidolet(:reddit){RedditApi.new('ProgrammerHumor')}context"#initializing"doit"should form the correct endpoint"doVCR.use_cassette('reddit_programmer_humor')doreddit.url.shouldeq"http://reddit.com/r/ProgrammerHumor/.json?after="endendendcontext"#fetching"doit"should fetch the first page of stories"doVCR.use_cassette('reddit_programmer_humor')doreddit.stories.count.shouldeq(25)endendendend
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.
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
123456789101112131415161718192021222324252627
require'spec_helper'require'./lib/reddit_api'describeRedditApidolet(:reddit){RedditApi.new('ProgrammerHumor')}context"#initializing"doit"should form the correct endpoint"doVCR.use_cassette('reddit_programmer_humor')doreddit.url.shouldeq"http://reddit.com/r/ProgrammerHumor/.json?after="endendendcontext"#fetching"doit"should fetch the first page of stories"doVCR.use_cassette('reddit_programmer_humor')doreddit.stories.count.shouldeq(25)endendit"should fetch the second page of stories"doVCR.use_cassette('reddit_programmer_humor_p2')doreddit.next.stories.count.shouldeq(25)endendendend
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
123
@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
123456789101112131415161718192021222324
require'spec_helper'require'stringio'require'./lib/reddit-cli'describeRedditClidolet(:subreddit){"ProgrammerHumor"}context"#initializing"dobefore(:all)do$stdout=@fakeout=StringIO.newendit"should print out a story"doapi_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').shouldbe_trueendendend
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.
require'./lib/reddit_api'require'terminal-table'classRedditClidefinitialize(api)@rows=[]@api=api@stories=api.storiesprint_storiesprint"\nType ? for help\n"promptenddefprint_stories@stories.each_with_index{|x,i|@rows<<[i,x.score,x.comments,x.title[0..79]]}putsTerminal::Table.new:headings=>['#','Score','Comments','Title'],:rows=>@rowsenddefpromptprint"\n?> "input=STDIN.gets.chompcaseinputwhen"?"p"Type the # of a story to open it in your browser"p"Type n to go to the next page"promptwhen"quit","q"when"n"@rows=[]@stories=@api.next.storiesprint_storiespromptelseprint"#=> Oepning: #{@stories[input.to_i].url}"`open #{@stories[input.to_i].url}`promptendendend
And finally, a little wrapper in the root directory:
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.