Building an ESPN App Using RubyMotion, ProMotion, and TDD

UDPATE 8/7/2014: ESPN is discontinuing their public API. Unfortunately, this means you won't be able to follow along with this guide. However, the concepts work for nearly any API, so it's still worth reading.

A good way to learn RubyMotion testing is by following a tutorial. In this article, I’ll test and build a simple ESPN iPhone app using RubyMotion, ProMotion, and TDD (Test Driven Development).

Without further ado, let’s get started. Here's the source.

Prep

Go get an ESPN developer account. It’s a really simple process and you should have an API key in no time.

App setup

Open Terminal. I assume you already have RubyMotion installed and are running Ruby 2.0+. Install ProMotion (gem install ProMotion) and then generate your project by running promotion new espn_app.

cd into that folder and open your favorite editor (mine is Sublime Text 3).

Remove the example screens, leaving only home_screen.rb. Also remove the models folder.

Screen Shot 2013-12-19 at 6,50,45 PM

Run bundle at the command line to make sure the gems are installed. You may have to add rake to your Gemfile.

Your first test

Let’s remove the main_spec.rb file. It’s not really doing anything useful.

The first thing we want to do is connect to the ESPN API. Let’s create a simple test for that.

Make a new file in spec called espn_spec.rb and put the following code in it (I encourage you to write it out rather than copy & paste):

# /spec/espn_spec.rb
describe ESPN do  
  before do
    @espn = ESPN.new
  end

  it "is the right class" do
    @espn.should.be.kind_of(ESPN)
  end
end  

Okay, this is also useless I realize. But you have to start somewhere.

Now go create the empty class in app/api/espn.rb.

class ESPN  
end

Run the spec with rake spec and you should get:

ESPN  
  - is the right class

1 specifications (1 requirements), 0 failures, 0 errors

Nice.

Automatic Tests

Before we move on, it’s going to get really annoying to have to re-run rake spec every time we want to compile and run the tests. You can use the powerful guard-motion, but for this simple app I’m going to turn to one of my favorite little gems, when-files-change.

$ gem install when-files-change
$ when-files-change "clear && bundle exec rake spec"

Now, whenever you save a file it will clear the screen and re-run your specs. No config!

Creating a table view

Create a new spec called home_screen_spec.rb:

describe HomeScreen do  
  tests HomeScreen

  def controller
    @controller ||= HomeScreen.new
  end
  alias :screen :controller

  it "is a TableScreen" do
    screen.should.be.kind_of(PM::TableScreen)
  end
end

With normal controller tests, @controller is automatically instantiated and added to a test @app by the tests method call. In ProMotion, the new method does additional setup, so we have to provide our own @controller by creating a controller method to instantiate it the ProMotion way.

Let’s go fix app/screens/home_screen.rb. Replace everything in it with the following:

class HomeScreen < PM::TableScreen  
  title "Home"

  def table_data
    []
  end
end

Your specs should pass!

HomeScreen  
  - is a TableScreen

2 specifications (2 requirements), 0 failures, 0 errors

The HomeScreen should load and display the latest headlines. Let’s do that now.

Connect to ESPN

In your espn_spec.rb file, add some lines:

  it "can get a response from ESPN now" do
    @espn.now do |response|
      response.should.be.kind_of(Hash)
      resume
    end
    wait {} # wait for the async request to finish
  end

ESPN Now is a publicly available API that returns a JSON response, so we’ll expect the .now method to return that. Since it’s an asynchronous call, we’ll use the wait and resume Bacon methods to make sure the tests wait for the response.

Your when-files-change will fire when you run that and give you an undefined method now error. No big deal, let’s go make that.

First, add AFMotion to your Gemfile:

source "https://rubygems.org"

gem "rake"  
gem "ProMotion", "~> 1.1.0"  
gem "afmotion", "~> 2.0.0"

Run bundle, then rake pod:install to install the gem and its required CocoaPod. Lastly, re-run your when-files-change "clear && bundle exec rake spec" command to automatically run tests as you make changes.

Now, let’s implement.

# app/api/espn.rb
class ESPN  
  API_KEY = "yourapikeyhere"
  NOW_URL = "http://api.espn.com/v1/now?apikey=#{API_KEY}"

  def now(&callback)
    AFMotion::JSON.get(NOW_URL) do |result|
      if result.success?
        callback.call result.object
      else
        callback.call nil
      end
    end
  end
end

Here we’re initiating a GET request and parsing it as JSON into a Hash object. Right now we’re just failing with nil; we’d eventually want to make this more useful.

Your tests should have run and you should get:

ESPN  
  - is the right class
  - can get a response from ESPN now

3 specifications (3 requirements), 0 failures, 0 errors

NOTE: If ESPN times out, just re-run the specs by pressing Enter. Unfortunately, I don’t know of a way in RubyMotion Bacon to specify a timeout. MacBacon has a self.timeout method, but this throws a NoMethodError in RubyMotion.

UPDATE: There's a workaround until RubyMotion is updated.

Populate the table view

Now that we have data and a table screen, let’s populate it.

First, the spec:

# spec/home_screen_spec.rb

  it "loads headlines" do
    screen.on_load
    wait 2 do
      screen.table_data.first[:cells].length.should.be > 0
    end
  end

We’re manually triggering the on_load method (which is normally triggered by ProMotion), waiting 2 seconds, and then checking to see if the table_data first section has been updated with some cells.

2 seconds should be enough. It would be nice if we could be notified when the data is ready, but let’s go with this for now.

Now the implementation:

# app/home_screen.rb

class HomeScreen < PM::TableScreen  
  title "ESPN Now"

  def table_data
    [{
      cells: Array(@headlines)
    }]
  end

  def on_load
    ESPN.new.now do |response|
      @headlines = response["feed"].map do |f|
        {
          title: f["headline"],
          action: :tap_headline,
          arguments: { links: f["links"] }
        }
      end
      update_table_data
    end
  end

  def tap_headline(args={})
    PM.logger.debug args[:links]
  end
end

I’m doing some extra ProMotion stuff here. Once I get the response from ESPN.new.now, I map some hashes (for the cells) that includes the headline title and a link to the mobile or web version of the story.

Your specs should all pass:

ESPN  
  - is the right class
  - can get a response from ESPN now

HomeScreen  
  - is a TableScreen
  - loads headlines

4 specifications (4 requirements), 0 failures, 0 errors

Tap to open story

Let’s write a test, checking to make sure the story URL is opened when you tap a cell. For this one, we’re going to mock the table_data method. Let’s use the motion-stump mocking gem.

# Gemfile
# ...
gem "motion-stump", "~> 0.3.0"  

Bundle and then restart your when-files-change watcher.

Now the test:

  it "opens the link URL when you tap a cell" do
    links = {
      "web" => { "href" => "http://www.google.com" }
    }

    stub_table_data = [{
      cells: [
        { title: "Test Title", action: :tap_headline, arguments: { links: links } }
      ]
    }]

    screen.stub!(:table_data, { return: stub_table_data })
    screen.update_table_data

    UIApplication.sharedApplication.mock! :"openURL:" do |url|
      url.should == NSURL.URLWithString("http://www.google.com")
    end

    tap view("Test Title")
  end

We create a little stub for table_data and return a hash that we previously set up. Then we update the table so it displays this new info. Then we set up a mock expectation for the application, checking to see if openURL: is called with the right URL when you tap the "Test Title" cell.

Let's implement this behavior.

# app/screens/home_screen.rb

  def extract_href(links)
    while links.is_a?(Hash)
      if links["href"]
        links = links["href"]
      else
        links = links.values.first # Extract value
      end
    end
    links
  end

  def tap_headline(args={})
    link = extract_href(args[:links])

    if link.is_a?(String)
      UIApplication.sharedApplication.openURL(NSURL.URLWithString(link))
    end
  end

We use a little logic to extract the "href" attribute (ESPN's API structure is somewhat inconsistent, so we have to do this). Then we check to make sure we got a string back and, if so, we open the URL.

ESPN  
  - is the right class
  - can get a response from ESPN now

HomeScreen  
  - is a TableScreen
  - loads headlines
  - opens the link URL when you tap a cell

5 specifications (5 requirements), 0 failures, 0 errors  

Great!

Pull to refresh

This isn’t super easy to test, but it’s easy to implement. So let’s just go straight to that:

class HomeScreen < PM::TableScreen  
  refreshable

  # ...

  def on_load
    on_refresh
  end

  def on_refresh
    ESPN.new.now do |response|
      @headlines = response["feed"].map do |f|
        {
          title: f["headline"],
          action: :tap_headline,
          arguments: { links: f["links"] }
        }
      end
      update_table_data
      stop_refreshing
    end
  end

  # ...
end  

Running the app

Since we know all the classes are working more or less like they should, it should be ready to go. Just run rake.

Screen Shot 2013-12-19 at 6,39,05 PM

Not very pretty, and some of the links don't work (because ESPN doesn't provide them). I'll provide more detail to this app in a future post (and update this post with a link when I do). UPDATE: No, I won't, I guess. ESPN is discontinuing their public API. :-(

The source on GitHub.

Going Forward

This is just a taste of TDD with RubyMotion, but it should get you started. Let me know on Twitter if you have questions or problems following this quick tutorial.

Follow me on Twitter for a lot more RubyMotion.