Ruby Behavior Driven Development Tutorial

Exercise 8: Testing Web Services

by Richard Kuehnel

This exercise covers the following topics.

  1. configuring Sinatra as a web server
  2. writing Sinatra routes
  3. writing Cucumber features to test a web service
  4. using Ruby's Net module for HTTP requests
  5. testing XML using the Nori gem
  6. common mistakes in comparing hashes for equality
  7. configuring Cucumber to launch Sinatra automatically

Sinatra

WEBrick is OK for testing, but Sinatra is much better for production. Sinatra is a lightweight wrapper around Rack, the interface between webservers and Ruby. It has a syntax that maps closely to functions exposed by HTTP verbs like GET and POST, making it ideal for web services. If your project does not need the complex domain models and view logic of Rails, then Sinatra is an excellent lightweight alternative.

REST (Representational State Transfer) has become the predominant web service design model and Ruby implements it quite elegantly. In this exercise you will use Sinatra to add a REST interface to your StatOwl API.

Create a new file bin/sinatra an insert this into it:

#!/usr/bin/env ruby

require 'sinatra/base'

class WebService < Sinatra::Base

  set :environment, :production
  set :port, 8080     # or your desired port

  get '/' do
    '<h1>Behavior Driven Development Rocks!</h1>'
  end

  run!

end

In statowl.gemspec add bin/sinatra as an executable and add the Sinatra gem as a runtime dependency:

  s.executables = ['statowl', 'webrick', 'sinatra']
...
  s.add_runtime_dependency('sinatra')
...

Make bin/sinatra executable and start up your new web server:

chmod +x bin/sinatra
bundle exec bin/sinatra

Point your browser to the root directory of your web service:

http://localhost:8080/

You should test that packaging and deployment work before adding any features, just like you did with the command line app. Execute the following to check that the gem deploys correctly:

rake clobber
rake package
gem install pkg/statowl-0.0.1.gem
sinatra

The shell command sinatra is now running from the installed gem, not bin/sinatra.

Alarm! Warning! Achtung!

Writing new features while leaving the deployed version of StatOwl installed locally as a gem can cause strange, seemingly inexplicable errors. Therefore, uninstall the gem and, when prompted, its executables:

gem uninstall statowl

Cycle One

The first interface you will implement accepts a GET request containing a comma-separated string of numbers and returns an HTML page with their statistics. The GET for the comma-separated numbers 1,2,3,4, for example, is

http://localhost:8080/statistics.html?numbers=1,2,3,4

You already have a well tested statistics API that generates HTML formatted statistics from an array, so you only need some basic Sinatra plumbing. Despite the apparent simplicity of your task, however, nothing gets coded without a failing test. Insert the following feature into a new file features/sinatra.feature:

Feature: Get statistics from a web service
  As the writer of a cool web app
  I want to get statistics via Ajax requests

  Scenario: Get HTML for 1,2,3,4
    Given my parameters are "numbers=1,2,3,4"
    And my web service is on "http://localhost:8080/"
    When I send a GET request to "statistics.html"
    Then the web service should respond with this XML:
      """
      <html>
        <head>
          <title>Statistics</title>
        </head>
        <body>
          <h1>Statistics</h1>
          <p>Mean: 2.5</p>
          <p>Median: 2.5</p>
          <p>Variance: 1.25</p>
          <p>Standard Deviation: 1.118033988749895</p>
        </body>
      </html>
      """

(You have specified "localhost" and "8080" several times already. Hard-coding these values throughout your code makes them difficult to change. In the final exercise you will learn how SettingsLogic makes it easy to specify configuration values in a YAML file so that they only have to be specified in one central location.)

Test your Cucumber features and insert the suggested code snippets into a new file features/step_definitions/sinatra_steps.rb.

Test again to see that you have one pending and three skipped tests.

For the first two steps you only need to store the values used for the test, so define both steps and verify that they pass:

Given(/^my parameters are "(.*?)"$/) do |parameters|
  @parameters = parameters
end

Given(/^my web service is on "(.*?)"$/) do |host|
  @host = host
end
...

Your Cucumber step results should go from 3 skipped, 1 pending, to 1 skipped and 1 pending. To create the URI for the GET request in the third step you can use Ruby's built-in URI module. To actually make the request, Ruby's Net module works nicely. Now define the third step:

require 'uri'
require 'net/http'

...

When(/^I send a GET request to "(.*?)"$/) do |path|
  uri = URI(@host + path + '?' + @parameters)
  @actual_response = Net::HTTP.get(uri)
end

Observe the simplicity of the HTTP GET request - if only Java were this easy! The Ruby standard library also includes open-uri, which allows web resources to be read the same way as files. Here is what that would look like:

require 'open-uri'
@actual_response = open(uri).read

Run your tests. They should fail because Sinatra is not running. Start up Sinatra in a separate terminal and try again:

bundle exec bin/sinatra

Do you have 1 pending step? Your pending step verifies that the HTML received matches the HTML expected. Lengthy string comparisons for this are problematic, because white space and other insignificant differences can generate false negatives. You will instead parse the expected and actual XHTML strings into hashes and then compare the hashes in a single line of code.

Add the 'xml-simple' gem as a development dependency in statowl.gemspec and define the fourth step in features/step_definitions/sinatra_steps.rb like this:

...

require 'xmlsimple'

...
Then(/^the web service should respond with this XML:$/) do |expected_xml|
  expected_hash = XmlSimple.xml_in(expected_xml)
  actual_hash = XmlSimple.xml_in(@actual_response)
  expect(actual_hash.eql?(expected_hash)).to eql(true)
end

It is important that the method Hash#eql? be used to get a deep check for identical hashes. Hash#eql and Hash#== only make shallow checks.

Check that the Cucumber scenario fails rather spectacularly. The reason is that Sinatra does not know how to render a web page that corresponds to /statistics.html. When Cucumber asks for it, Sinatra returns a default error page that not valid XHTML, because it contains an image tag that looks like <img src="..."> instead of <img src="..."/>. Check out the web page yourself by typing the URL address into your browser:

http://localhost:8080/statistics.html

(Insert the host and port that you are using.)

The only path you have implemented for Sinatra is /. Since /statistics.html is undefined, Sinatra gives you a code snippet to show you how to map the path to a "Hello World" response. Try it out by inserting the snippet into bin/sinatra. Make sure you place it above the invocation of the run! method. You will need to restart Sinatra for the change to take effect.

What is the most trivial implementation of the /statistics.html route that returns the response that your Cucumber test expects?

Absolutely correct - you have grown accustomed to this question by now. Make your test pass by removing "Hello World" from bin/sinatra and replacing it with a correct, but hard-coded string:

  get '/statistics.html' do
    content_type 'text/html'
    """
      <html>
        <head>
          <title>Statistics</title>
        </head>
        <body>
          <h1>Statistics</h1>
          <p>Mean: 2.5</p>
          <p>Median: 2.5</p>
          <p>Variance: 1.25</p>
          <p>Standard Deviation: 1.118033988749895</p>
        </body>
      </html>
    """
  end

Restart Sinatra and the scenario should pass.

Tip: A common mistake is to allow erroneous inputs to create empty hashes, because two empty hashes are always equal. A good double check is to make a minor change in the response (by setting the variance to 1.26, for example) to make sure that the test fails.

You have a passing test - time to Refactor!

Tests should be automated and simple to run. Your project is overly complicated because it requires developers to run the Sinatra-based web service and Cucumber scenarios in separate terminals. If you do not correct the situation, then you will have to write additional testing instructions that will clutter your elegantly written README file. Ruby should work right out of the box. This is especially true for tests because otherwise developers will skip them. Add the following to the file features/support/env.rb to start Sinatra before all Cucumber tests and to stop it afterwards. (Remember: env.rb has a special significance to Cucumber. If the file exists then Cucumber executes it before all others.)

...
require 'childprocess'
require 'timeout'
require 'net/http'
require 'uri'

...

host = 'localhost'
port = '8080'
puts 'Starting Sinatra, host=' + host + ', port=' + port + ' ...'
server = ChildProcess.build('bundle', 'exec', 'sinatra')
server.start
Timeout.timeout(30) do
  loop do
    begin
      uri = URI('http://' + host + ':' + port + '/')
      response = Net::HTTP.get(uri)
      break
    rescue Errno::ECONNREFUSED => error
      sleep 0.1
    end
  end
end

at_exit do
  server.stop
end

Stop the Sinatra server that is running in a separate terminal. Then run your Cucumber scenarios again. They are now fully automated.

Your working web service only serves up hardcoded HTML. This is not very useful! Triangulation is not required in this case, because you already have fully automated RSpec tests for lib/statowl/extensions.rb, which handles the business logic of computing statistics and formatting them for HTML. Replacing the hardcoded response therefore represents simple refactoring. Your web service has a passing test and you are not adding a new feature, so continue refactoring. Modify bin/sinatra to compute real statistics using your extensions to the Array class:

...
require_relative '../lib/statowl/extensions'

...
  get '/statistics.html' do
    content_type 'text/html'
    array = [1,2,3,4]
    array.statistics_as_html
  end
...

Are your tests still green? The array is still hard coded, so alter the Sinatra route to read the actual parameters that are passed with the GET request.

...
  get '/statistics.html' do
    content_type 'text/html'
    array = "#{params[:numbers]}".split(',').as_f
    array.statistics_as_html
  end
...

Cycles Two and Three

Implement routes for XML and JSON. Here are some hints.

  1. Begin each cycle with a new Cucumber scenario.
  2. During refactoring, look for ways to remove redundancy in your scenarios.
  3. Content types are text/xml and application/json.
  4. echo '1,2,3,4' | bundle exec bin/statowl -f xml all gives you XML that you can paste into a Cucumber scenario.

Exercise Summary

In this exercise you configured Sinatra as a web server. Then you used Behavior Driven Development to create a RESTful web service for the StatOwl API. You also practiced parsing XML and making HTTP requests using Ruby's Net module.

Over the past few years, software development has shifted the rendering of views from the server to the browser. Moreover, NoSQL is making headway against the traditional object relational model. As a result, server-side code is becoming simpler and more RESTful. Under these conditions, Sinatra is an excellent lightweight alternative to the complex domain models and view logic of Rails.

In the next exercise you will learn how to package and deploy your RubyGem for easy user interaction and installation.