Beginning Outside-In Rails Development with Cucumber and RSpec

The RSpec Book defines outside-in Rails development as starting with views and working your way in toward the models. By developing from the outside in, you are always taking a client perspective at each layer of the application. The end result is an absolute minimum implementation, consisting of simple, expressive interfaces.

Outside-in development doesn’t require any specific tool or language. This article will demonstrate it in Rails, using two popular testing tools: Cucumber and RSpec.

Start with a High-level Specification

Starting with a high-level specification requires you to have a clear understanding about what you want to achieve. If it’s still unclear, now is the time to have a conversation with the client. After establishing a clear goal, we can use Cucumber to turn a plaintext story into executable code.

Our sample story will be from a news site. The feature is a JSON API endpoint for news articles.

In order to reference published articles in other applications
As an API client
I want to be able to request articles via a JSON API

This story can be copied directly into a Cucumber feature.

features/api/v1/articles.feature

Feature: Articles API 
  In order to reference published articles in other applications
  As an API client
  I want to be able to request articles via a JSON API 

  Scenario: Get articles
    Given some published articles
      And some unpublished articles
    When I ask for articles from the API 
    Then I should only receive published articles as JSON

Let’s run this feature to figure out what to do next.

$ cucumber features/api/v1/articles.feature
Using the default profile...
UUUU

1 scenario (1 undefined)
4 steps (4 undefined)
0m0.002s

You can implement step definitions for undefined steps with these
snippets:

Given /^some published articles$/ do
  pending # express the regexp above with the code you wish you had
end

Given /^some unpublished articles$/ do
  pending # express the regexp above with the code you wish you had
end

When /^I ask for articles from the API$/ do
  pending # express the regexp above with the code you wish you had
end

Then /^I should only receive published articles as JSON$/ do
  pending # express the regexp above with the code you wish you had
end

Fantasy Coding

Cucumber successfully parsed our feature but it needs definitions for all of our steps. Let’s implement these steps writing code that we wish already existed.

features/step_definitions/api/v1/articles_steps.rb

Given /^some published articles$/ do
  FactoryGirl.create_list :published_article, 3
end

Given /^some unpublished articles$/ do
  FactoryGirl.create_list :unpublished_article, 3
end

When /^I ask for articles from the API$/ do
  header 'Accept', 'application/json'
  get '/api/v1/articles'
end

Then /^I should only receive published articles as JSON$/ do
  articles_json = JSON last_response.body
  articles_json.should have(3).published_articles

  published_articles = Article.all.select {|article| article.published?}
  published_articles.should_not be_empty
  published_articles.each do |published_article|
    article_json = articles_json.detect do |article_json|
      article_json['title'] == published_article.title
    end
    article_json.should be
  end
end

In our two Given steps, we establish a context consisting of published and unpublished articles. These two factories don’t exist yet; we just wrote the code we wish we had. This is a major benefit of developing from the outside in. By writing code that doesn’t even exist, you’ll end up creating ideal objects and interfaces.

In our When step, we exercise our application by first setting a proper HTTP header and then making an HTTP GET request to a non-existent URL. Again, this URL doesn’t exist, it’s just where we would expect the articles to be.

Finally in our Then step, we verify the response. Here we begin to discover our domain model; imagining an Article class with a #published? instance method.

Let Cucumber Guide the Way

With our steps defined, a rerun of Cucumber fails in our Given step because our factories don’t exist yet.

$ cucumber features/api/v1/articles.feature
Using the default profile...
F---

(::) failed steps (::)

Factory not registered: published_article (ArgumentError)
./features/step_definitions/api/v1/articles_steps.rb:2:in `/^some
published articles$/'
features/api/v1/articles.feature:7:in `Given some published articles'

Failing Scenarios:
cucumber features/api/v1/articles.feature:6 # Scenario: Get articles

Let’s define these two factories.

spec/factories/articles.rb

FactoryGirl.define do
  factory :article do
    sequence(:title) {|n| "title-#{n}"}

    factory :published_article do
      published true
    end

    factory :unpublished_article do
      published false
    end
  end
end

Cucumber guides us toward our next step.

$ cucumber features/api/v1/articles.feature
Using the default profile...
F---

(::) failed steps (::)

uninitialized constant Article (NameError)
./features/step_definitions/api/v1/articles_steps.rb:2:in `/^some
published articles$/'
features/api/v1/articles.feature:7:in `Given some published articles'

Failing Scenarios:
cucumber features/api/v1/articles.feature:6 # Scenario: Get articles

Our factories are being automatically mapped to a non-existent Article class. We can use the Rails model generator to create this class. We’ll also specify the title and published attributes we referenced in our Then step.

$ rails g model article title published:boolean
    invoke  active_record
    create    db/migrate/20120213050147_create_articles.rb
    create    app/models/article.rb
    invoke    rspec
    create      spec/models/article_spec.rb
$ rake db:migrate db:test:prepare
==  CreateArticles: migrating
=================================================
-- create_table(:articles)
   -> 0.0497s
==  CreateArticles: migrated (0.0498s)
========================================

With our setup passing, Cucumber now fails at our API request.

$ cucumber features/api/v1/articles.feature
Using the default profile...
..F-

(::) failed steps (::)

No route matches [GET] "/api/v1/articles"
(ActionController::RoutingError)
./features/step_definitions/api/v1/articles_steps.rb:11:in `/^I ask for
articles from the API$/'
features/api/v1/articles.feature:9:in `When I ask for articles from the
API'

Failing Scenarios:
cucumber features/api/v1/articles.feature:6 # Scenario: Get articles

Drill Down to the Controller

We need to add a route for our API endpoint.

config/routes.rb

Sample::Application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :articles
    end
  end
end

$ cucumber features/api/v1/articles.feature
Using the default profile...
..F-

(::) failed steps (::)

uninitialized constant Api (ActionController::RoutingError)
./features/step_definitions/api/v1/articles_steps.rb:11:in `/^I ask for
articles from the API$/'
features/api/v1/articles.feature:9:in `When I ask for articles from the
API'

Failing Scenarios:
cucumber features/api/v1/articles.feature:6 # Scenario: Get articles

With the routes in place, Cucumber tells us we’re missing a constant. This particular error message is from the Rails #namespace method we used in our routes file. Namespacing our controller will fix this issue. We can use the Rails controller generator to create a namespaced controller class.

$ rails g controller api::v1::articles
    create  app/controllers/api/v1/articles_controller.rb
    invoke  erb
    create    app/views/api/v1/articles
    invoke  rspec
    create    spec/controllers/api/v1/articles_controller_spec.rb
    invoke  helper
    create    app/helpers/api/v1/articles_helper.rb
    invoke    rspec
    create      spec/helpers/api/v1/articles_helper_spec.rb
    invoke  assets
    invoke    coffee
    create      app/assets/javascripts/api/v1/articles.js.coffee
    invoke    scss
    create      app/assets/stylesheets/api/v1/articles.css.scss

Cucumber fails again, this time looking for an #index action in our controller.

$ cucumber features/api/v1/articles.feature
Using the default profile...
..F-

(::) failed steps (::)

The action 'index' could not be found for Api::V1::ArticlesController
(AbstractController::ActionNotFound)
./features/step_definitions/api/v1/articles_steps.rb:11:in `/^I ask for
articles from the API$/'
features/api/v1/articles.feature:9:in `When I ask for articles from the
API'

Failing Scenarios:
cucumber features/api/v1/articles.feature:6 # Scenario: Get articles

At this point, some outside-in practitioners will also drop down a level with respect to testing and use RSpec to spec out the controller. Since we’re new to outside-in development, I’m going to skip this step and go straight to the implementation. We’ll look at the value of controller specs and when it makes sense to write them later on.

app/controllers/api/v1/articles_controller.rb

class Api::V1::ArticlesController < ApplicationController
  def index
  end
end

$ cucumber features/api/v1/articles.feature
Using the default profile...
..F-

(::) failed steps (::)

Missing template api/v1/articles/index, application/index with
{:locale=>[:en], :formats=>[:json], :handlers=>[:erb, :builder,
:jbuilder, :coffee]}. Searched in:
  * "/Users/jared/Projects/sample/app/views"
 (ActionView::MissingTemplate)
/Users/jared/.rbenv/versions/1.9.2-p290/lib/ruby/1.9.1/benchmark.rb:310:in
`realtime'
./features/step_definitions/api/v1/articles_steps.rb:11:in `/^I ask for
articles from the API$/'
features/api/v1/articles.feature:9:in `When I ask for articles from the
API'

Failing Scenarios:
cucumber features/api/v1/articles.feature:6 # Scenario: Get articles

Now we fail because our #index action needs a template. Let’s create an empty template just so we can finally get a non-infrastructure related failure, i.e., a logic error, from Cucumber.

$ touch app/views/api/v1/articles/index.json.jbuilder
$ cucumber features/api/v1/articles.feature
Using the default profile...
...F

(::) failed steps (::)

expected 3 published_articles, got 0
(RSpec::Expectations::ExpectationNotMetError)
./features/step_definitions/api/v1/articles_steps.rb:16:in `/^I should
only receive published articles as JSON$/'
features/api/v1/articles.feature:10:in `Then I should only receive
published articles as JSON'

Failing Scenarios:
cucumber features/api/v1/articles.feature:6 # Scenario: Get articles

Drill Down to the View

With the routing and request handling boilerplate out of the way, we finally get a “legitimate” failure from Cucumber. At this point, some outside-in practitioners will drop down a level with respect to testing and use RSpec to spec out the view. Like controller specs, I’m going to skip this step for simplicity. We’ll discuss when a view spec makes sense later on. For now, let’s update our blank view to actually render some JSON (we’ll use the jbuilder Gem to construct the JSON).

app/views/api/v1/articles/index.json.jbuilder

json.array! @articles do |json, article|
  json.title article.title
end

Again, we write this view imagining an “articles” instance variable; ideally, a collection containing our articles. This helps us avoid setting up unnecessary state in our action.

Cucumber fails again, this time with a long stacktrace (abbreviated below) originating in jbuilder.

$ cucumber features/api/v1/articles.feature
Using the default profile...
..F-

(::) failed steps (::)

undefined method `empty?' for nil:NilClass (ActionView::Template::Error)

Failing Scenarios:
cucumber features/api/v1/articles.feature:6 # Scenario: Get articles

This failure is because the instance variable doesn’t exist yet. Let’s update our Api::V1::ArticlesController#index action to find all published articles.

app/controllers/api/v1/articles_controller.rb

class Api::V1::ArticlesController < ApplicationController
  def index
    @articles = Article.published
  end
end

We’ve kept our controller thin and decided to not directly test it. By keeping controller logic to a minimum, skipping controller tests isn’t a significant risk.

Cucumber now guides us to our domain model.

$ cucumber features/api/v1/articles.feature
Using the default profile...
..F-

(::) failed steps (::)

undefined method `published' for #<Class:0x007ff13c529498>
(NoMethodError)
./app/controllers/api/v1/articles_controller.rb:3:in `index'
./features/step_definitions/api/v1/articles_steps.rb:11:in `/^I ask for
articles from the API$/'
features/api/v1/articles.feature:9:in `When I ask for articles from the
API'

Failing Scenarios:
cucumber features/api/v1/articles.feature:6 # Scenario: Get articles

Drill Down to the Model

Despite skipping controller and view specs, I do feel it’s beneficial to drill down a layer in our tests and directly test the model. Model tests will shorten our testing feedback loop and allow us to specify at a level closer to the code. Skipping model tests and relying on Cucumber, keeps the feedback loop too large, slowing you down.

spec/models/article_spec.rb

require 'spec_helper'

describe Article do
  describe '.published' do
    before do
      FactoryGirl.create_list(:published_article, 2)
      FactoryGirl.create_list(:unpublished_article, 1)

      @published = Article.published
    end

    it 'only returns published articles' do
      @published.should_not be_empty
      @published.each do |article|
        article.should be_published
      end
    end
  end
end

RSpec will now be our guide.

$ rspec spec/models/article_spec.rb
F

Failures:

  1) Article.published only returns published articles
     Failure/Error: @published = Article.published
     NoMethodError:
       undefined method `published' for #<Class:0x007fee85f72440>
     # ./spec/models/article_spec.rb:9:in `block (3 levels) in <top
     # (required)>'

Finished in 0.03521 seconds
1 example, 1 failure

app/models/article.rb

class Article < ActiveRecord::Base
  def self.published
  end
end

$ rspec spec/models/article_spec.rb
F

Failures:

  1) Article.published only returns published articles
     Failure/Error: @published.should_not be_empty
     NoMethodError:
       undefined method `empty?' for nil:NilClass
     # ./spec/models/article_spec.rb:13:in `block (3 levels) in <top
     # (required)>'

Finished in 0.03338 seconds
1 example, 1 failure

app/models/article.rb

class Article < ActiveRecord::Base
  def self.published
    where :published => true
  end
end

$ rspec spec/models/article_spec.rb
.

Finished in 0.10455 seconds
1 example, 0 failures

With Article.published specified, we can return to Cucumber.

Jump Back Up to Cucumber

Cucumber is now passing and our story is complete.

$ cucumber features/api/v1/articles.feature
Using the default profile...
....

1 scenario (1 passed)
4 steps (4 passed)

The complete code for this example can be found on github.

Change Your Perspective

I use the above approach on every feature I write. At each layer, I find myself getting lazier and lazier, delaying the hard work until the very end. At that point, I’m in the domain model, the heart of the application, and where the majority of logic should be. By taking a client perspective at each layer, the resulting objects remain simple, have expressive interfaces, and logic naturally finds its home.

You can begin developing from the outside in at every one of your application’s interfaces. The example above demonstrated a JSON API. Traditional HTML interfaces can easily be tested using capybara. And if you’re developing a command line interface, perhaps for a Ruby gem, take a look at aruba.

Do I Need to Test at Every Layer?

The RSpec book suggests writing tests at each layer, i.e., view specs, controller specs, helper specs, and model specs. I’ve tried this approach several times but I usually feel all the lower level specs, except model specs, aren’t worth it. They do shorten the testing feedback loop, but their reliance on stubbing and mocking to achieve true isolation makes refactoring and maintenance difficult. I also keep the logic to such a minimum in these objects, e.g., controllers, that the additional fine-grained unit tests don’t provide that much benefit.

There is nothing wrong with not unit testing each part of your application. Don’t dogmatically insist that everything be unit tested. Oftentimes a higher-level integration test will sufficiently exercise (albeit indirectly) a particular piece of code. The tradeoff here is that your testing feedback loop will be large. You’ll need to execute a full-stack Cucumber test just to see if a change you made, perhaps in a controller or a view, passes your failing test. Occasionally, I’ll use a lower level view or controller test to handle an edge case but this is a pretty rare occurrence. This is just my personal style. I would recommend trying out testing at every level, especially view and controller tests, to see how it feels and if it’s beneficial for you and your team.

Give It A Try

Outside-in development often feels strange to newcomers. Most developers prefer to start with the “important” part of an application, i.e., the domain model, and work their way outwards. Thinking like a server and not a client can lead to overengineering by implementing more than you need. Your resulting objects and their interfaces will also be less than optimal, or at least take longer to get quite right.

Like most things in Rails, the tools for developing outside-in are easy to setup and configure. If Cucumber or RSpec aren’t your thing, adapt your favorite tool and give outside-in development a try.


About Jared Carroll

After a short stint in the fashion industry Jared found his true calling at Carbon Five. Yes... he looks like a serial killer in this photo. But really he is as gentle as a flower.
This entry was posted in Process, Web and tagged , , , , . Bookmark the permalink.
  • Rit Li

    … but I usually feel all the lower level specs, except model specs, aren’t worth it.

    Why only model specs? Why not unit test controller specs?

    • Jared Carroll

      I write model specs because I like the shorter test cycle. Having to run an integration test just to test drive a method in a model is overkill. However, using an integration test to test drive a view or controller is a short enough test cycle for me, so I usually skip directly unit testing controllers and views.

  • Travis Herrick

    What part of your test required the coffeescript files created by the generator? By using generators, you are not letting the tests guide you as they should. Additional cruft is unnecessarily added to the project.

    • Jared Carroll

      That’s a good point. The Rails generators do generate extra unnecessary files, however I feel they’re a good tool for guiding beginners.

  • Pingback: Pie in The Sky (February 17, 2012) | MSDN Blogs

  • Pingback: Beginning Outside-In Rails Development with... | Agile | Syngu

  • http://www.facebook.com/people/Said-Kaldubaev/100001506759434 Said Kaldubaev

    Nice example, thx for sharing

  • http://twitter.com/andyw8 Andy Waite

    Hi, I wanted to read this article using Instapaper on my Kindle but the Gists are missing. Any chance of providing the code within elements so that I can read this and other Carbon Five posts through Instapaper?

    • Jared Carroll

      Andy,

      I installed a GitHub Gist WordPress plugin that injects the generated HTML into the post. How does it look now?

  • Pingback: Rails 3 and Cucumber: getting started with outside-in testing

  • http://www.facebook.com/people/Gil-Shahrabany/100000971354293 Gil Shahrabany

    Hi
    I need to write a scenario that test an app-this app use mogli/facebooker2 plugin
    And i need to integrate the cucumber (or something else ) with the mogli/facebooker2 ….
    Do you know any idea or direction how can i do that?
    Do you know any example about scenario with ability to login to a facebook page ?
    I suggested to use mock library … do you know something or direction about that ?
    I prefer still working with cucumber but i don’t know how to integrate it with facebooker2 ….
    I will thanks to hear if you have some light about that issue
    thank,Gil

    • http://blog.carbonfive.com/ Jared Carroll

      Hi Gil,

      I don’t have any experience using the mogli or facebooker2 gems. However, whenever I have a dependency on an external API, I’ll stub it out in tests. https://github.com/myronmarston/vcr is an excellent gem for recording and re-playing HTTP requests during tests. It can be easily integrated into Cucumber: https://www.relishapp.com/myronmarston/vcr/v/2-0-0/docs/test-frameworks/usage-with-cucumber

      If I had an app that integrated with a Facebook API, I would use vcr to record actual Facebook API requests and responses, save them to the filesystem, and then replay them during future test runs. This would eliminate the Facebook API dependency during testing and also make the tests more predictable, consistent, and faster.

      • http://www.facebook.com/people/Gil-Shahrabany/100000971354293 Gil Shahrabany

        Thanks !!!

        • http://blog.carbonfive.com/ Jared Carroll

          Gil,

          I recently wrote up a post on external API testing that covers some basic vcr usage:

          http://blog.carbonfive.com/2012/03/18/how-to-test-external-apis/

          • http://www.facebook.com/people/Gil-Shahrabany/100000971354293 Gil Shahrabany

            Very helpful !!!,
            Your strategy to solve integration problem solved me a lot of headache
            Thanks for updating me!!!
            And for sharing !!!
            Best,Gil

  • Glyn Mooney

    Well that’s the missing chapter from every programming book ever made!

  • http://www.facebook.com/leonid.dinershtein Leonid Dinershtein

    You have used features/api/v1/… paths for features and features/step_definitions/api/v1/… for step definitions, but there is big chance that you would like to have “Given some published articles” and many others steps in v2 API, but steps already described in step_definistions/api/v1/.

    What do you thing about this, does this name conventions works in real apps?

    • Jared Carroll

      If version 2 of the API isn’t backward compatible, then a separate directory for both v1 and v2 step definitions would be a good way to keep their differences separate. However, if the API doesn’t change significantly, then refactoring to a more generic directory structure is probably a good solution.

  • Pingback: Quora

  • http://grilix.net/ Gonzalo Arreche

    Excellent guide, I will try this. Thank you!.