Ruby Behavior Driven Development Tutorial

Exercise 10: Testing Data Repositories

by Richard Kuehnel

This exercise covers the following topics.

  1. basic CRUD for data persistence
  2. how CRUD maps to REST
  3. using the MongoDB Ruby API
  4. writing RSpec unit tests for CRUD operations
  5. using Sinatra to wrap a RESTful web service around a database
  6. CRUD acceptance tests for a web interface

CRUD

Create, read, update, and delete (CRUD) are the four basic functions of persistent data storage. You can

  1. Create or add new entries
  2. Read, retrieve, search, or view existing entries
  3. Update or edit existing entries
  4. Delete existing entries

REST (Representational State Transfer) has become the predominant web service design model and Ruby implements it quite elegantly. For a REST interface, CRUD has a one-to-one mapping to HTTP operations:

Persistance Operation           HTTP Operation
Create POST
Read GET
Update PUT
Delete DELETE

You will create a web service to store and retrieve sets of numbers upon which statistics can be computed. You will accomplish this in two steps.

First, you will write a data persistence API for the four CRUD operations. For this you will use MongoDB, a NoSQL database. (This exercise assumes you have installed MongoDB and that it is running on the default port: 27017.) Your MongoDB API will be used internally by your application, but not directly by end users, so RSpec unit tests make sense here.

Second, you will wrap the API in a web service. For that you will use Sinatra. It has a syntax that easily maps HTTP requests to CRUD operations, making it ideal for web services. You will use automated Cucumber acceptance tests for the externally exposed web interface.

MongoDB - Cycle One

Add a runtime dependency for the 'mongo' gem to your statowl.gemspec file.

Create a new file spec/mongo_spec.rb and insert this into it:

require 'net/http'
require_relative '../lib/statowl/mongo_repository'

describe 'A repository for numbers' do
  mongo_client = Mongo::Client.new("mongodb://localhost:27017/statowl_database")
  collection = mongo_client[:statowl_stored_numbers]
  repository = Statowl::MongoRepository.new(collection)

  it 'should store numbers' do
    label = 'important_numbers'
    numbers = '1,2,3,4'
    repository.create(label, numbers)
    result = repository.read(label)
    expect(result).to eq(numbers)
  end

end

Notice that your Statowl::MongoRepository methods are create and read to emphasize CRUD operations and their mapping to REST, which you will implement later. Most developers use the methods save and find instead.

Execute rake spec and observe a warning that a file containing the new class Statowl::MongoRepository does not exist. This is typical for Test Driven Development. You wrote your test first. You "used the code you wish you had."

Create a new file lib/statowl/mongo_repository.rb and insert the minimum amount of code needed to make the test pass. (MongoDB must be running.)

require 'mongo'

module Statowl

  class MongoRepository

    def initialize(collection)
    end

    def create(label, numbers)
    end

    def read(label)
      '1,2,3,4'
    end

  end

end

All green? There isn't much here to refactor.

Cycle Two

Add a new test with a different set of numbers that makes the hard-coded solution fail:

...

  it 'should store some different numbers' do
    label = 'important_numbers'
    numbers = '5,6,7,8'
    repository.create(label, numbers)
    result = repository.read(label)
    expect(result).to eq(numbers)
  end

...

Check that it fails and then make it pass by adding MongoDB operations to lib/statowl/mongo_repository.rb. This implementation uses MongoDB as a repository of key-value pairs. In this case the value contains the numbers you want to store and the key is their label.

require 'mongo'

module Statowl

  class MongoRepository

    def initialize(collection)
      @collection = collection
    end

    def create(label, numbers)
      @collection.delete_many({ 'key' => label })
      @collection.insert_one({ 'key' => label, 'value' => numbers })
    end

    def read(label)
      documents = @collection.find({ 'key' => label})
      documents.first['value'] if documents.any?
    end

  end

end

Refactor?

Cycle Three

You have implemented the first two CRUD features. Create is implemented by the create method and Read is implemented by the read method. Add a test to update numbers that are already stored.

...

  it 'should update numbers' do
    label = 'important_numbers'
    old_numbers = '1,2,3,4'
    new_numbers = '5,6,7,8'
    repository.create(label, old_numbers)
    repository.update(label, new_numbers)
    result = repository.read(label)
    expect(result).to eq(new_numbers)
  end

...

Make sure that it fails because the update method does not exist. Because you only allow one value per key, and update operation is essentially the same as a create operation. Use Ruby's built-in alias method to map update to create in lib/statowl/mongo_repository.rb:

...

    alias :update :create

...

Nine lines of test code passes with only a single line of production code - bravo! Refactor?

Cycle Four

Add a test for Delete, the final CRUD operation:

...

  it 'should delete numbers' do
    label = 'trash'
    repository.create(label, '1,2,3')
    repository.delete(label)
    result = repository.read(label)
    expect(result).to eq(nil)
  end

...

Verify that it fails and then make it pass:

...

    def delete(label)
      @collection.delete_many({ 'key' => label })
    end

...

That was easy! You have just written a functional CRUD API that is fully covered by automated tests. Think about how long it would have taken to implement and test the API by combining Java, MySQL, and Hibernate annotations! Notice the readability of the RSpec console output.

All tests are green. Any final refactoring? Add require 'statowl/mongo_repository' to lib/statowl.rb. Next you will expose your CRUD API as a REST service using Sinatra.

Cycle Five - REST

Web interfaces are designed to be used by external clients, so Cucumber Acceptance Tests are appropriate. Create a new file features/mongo_web_service.feature and insert a new feature:

Feature: A web service to store and retrieve arrays
  As the writer of a cool web app
  I want to store arrays in a database
  And retrieve them via a web service

  Scenario: Store and retrieve an array
    Given my URI is "http://localhost:8080/mongo/my_important_numbers"
    When I "POST" the numbers "1,2,3,4"
    And I execute a "GET" request
    Then the JSON should equal
      """
      {
        "label": "my_important_numbers",
        "array": "1,2,3,4"
      }
      """

Run rake features and observe that you have some undefined steps. Copy the code suggested code snippets to a new file features/step_definitions/mongo_rest_steps.rb and observe that you now have skipped and pending steps.

Implementing the first step is trivial, because you only have to save the given value for future steps:

Given(/^my URI is "(.*?)"$/) do |uri|
  @uri = uri
end

To implement the next step you can use Ruby's Net module to make the POST:

require 'net/http'
require 'uri'

...
When(/^I "(.*?)" the numbers "(.*?)"$/) do |method, csv|
  s = @uri.split('/')
  label = s[s.size - 1]
  case method
  when 'POST'
    Net::HTTP.post_form(URI(@uri), { "array" => csv })
  else
    raise 'invalid HTTP method!'
  end
end
Amazingly this step passes, despite the fact that you have not yet implemented a Sinatra route for /mongo/my_important_numbers. Why does this step pass?
You are not verifying that the POST was successful.
  

You can also use the Net module for the GET request in the next step:

When(/^I execute a "(.*?)" request$/) do |method|
  case method
  when 'GET'
    @actual_response = Net::HTTP.get( URI(@uri) )
  else
    raise "Invalid HTTP method!"
  end
end

This step passes for the same reason as the prior step. Define the final step:

Then(/^the JSON should equal$/) do |expected_JSON|
  expected_hash = JSON.parse(expected_JSON)
  actual_hash = JSON.parse(@actual_response)
  expect(actual_hash.eql?(expected_hash)).to eq(true)
end

This fails, because the HTML from Sinatra's 404 "page not found" error cannot be parsed as JSON. You can read about the test failure, which is highlighted in red about halfway down the console output. For a better look at the HTML output, however, it is better to point your browser to the route you attempted to GET. Execute bundle exec bin/sinatra and then point your browser to

http://localhost:8080/mongo/my_important_numbers

As you can see, a web browser is handy for testing GET requests, especially with Firebug installed. To test POST, PUT, and DELETE requests you can use the handy Firefox add-on "Poster."

Make the new feature pass by implementing the POST and GET routes in bin/sinatra:

...

require 'uri'
require_relative '../lib/statowl/mongo_repository'

...

  post '/mongo/:label' do
    content_type 'application/json'
    mongo_client = Mongo::Client.new("mongodb://localhost:27017/statowl_database")
    collection = mongo_client[:statowl_stored_numbers]
    repository = Statowl::MongoRepository.new(collection)
    label = URI.decode( params[:label] )
    array = URI.decode(request.body.read.split('=')[1] )
    repository.create(label, array)
    { 'label' => label, 'array' => array }.to_json
  end

  get '/mongo/:label' do
    content_type 'application/json'
    content_type 'application/json'
    mongo_client = Mongo::Client.new("mongodb://localhost:27017/statowl_database")
    collection = mongo_client[:statowl_stored_numbers]
    repository = Statowl::MongoRepository.new(collection)
    label = URI.decode( params[:label] )
    array = repository.read(label)
    { 'label' => label, 'array' => array }.to_json
  end

...

Your tests pass, but there is some obvious redundancy in the Sinatra routes. There is an important issue that needs to be addressed right away, however, so save the refactoring for the next cycle.

Cycle Six

There is a very subtle trap in your test of the POST route. Take a look at features/mongo_web_service.feature. The POST is only verified indirectly via the subsequent GET. If the POST fails, then GET returns any values that may already be stored in the database. It is conceivable that POST could break today and for days or weeks your tests could continue to pass because of the old stored data.

To see for yourself, change the port number for the POST route from 27017 to 27018 in bin/sinatra and run your tests again. Without the correct port number Sinatra cannot connect to MondoDB to save the data.

Now run your tests again. Still green?

The fix is to purge the database before each test run or to triangulate so that static data will not pass.

Keep the incorrect port number and add another scenario where the label is the same but the data array is different. One of the two tests now fails. Change the port number back to 27017 to make it pass.

Test Driven Development creates an environment for fast test-code-refactor cycles, because there is less worry about breaking the existing production code. Risks are lower, which facilitates aggressive refactoring. The presumed safety net depends on the quality of the tests, however. Sometimes it is a good idea to do a quick "test of the test," as you did by changing the MongoDB port number, to assure yourself that your assumed safety net really exists.

Your tests are passing so it is time to refactor. For both GET and POST you connect to the database, do something with it, and then close the connection. In this situation, Ruby programmers frequently use "Extract Surrounding Method" refactoring. To apply Extract Surrounding Method here, you can create a method that connects to MongoDB, creates an instance of MongoRepository, yields that instance to the GET or POST that invokes it, and then closes the connection:

...
  def connect_to_database
    mongo_client = Mongo::Client.new("mongodb://localhost:27017/statowl_database")
    collection = mongo_client[:statowl_stored_numbers]
    repository = Statowl::MongoRepository.new(collection)
    yield repository
  end

  post '/mongo/:label' do
    content_type 'application/json'
    connect_to_database do |repository|
      @label = URI.decode( params[:label] )
      @array = URI.decode(request.body.read.split('=')[1] )
      repository.create(@label, @array)
    end
    { 'label' => @label, 'array' => @array }.to_json
  end

  get '/mongo/:label' do
    content_type 'application/json'
    connect_to_database do |repository|
      @label = URI.decode( params[:label] )
      @array = repository.read(@label) || ""
    end
    { 'label' => @label, 'array' => @array }.to_json
  end
...

The routes are much leaner. Are your test still green?

Cycle Seven

Write a new feature to delete the data for a given label.

  1. Begin by writing a new Cucumber feature. A GET request for the label "my_important_numbers" returns this if there is no data stored in the database for it:

    {"label":"my_important_numbers","array":""}
    
  2. Here is how to send an HTTP DELETE to port 8080 of the server "localhost":

        uri = URI("http://localhost:8080/mongo/my_important_numbers")
        http = Net::HTTP.new(uri.host, uri.port)
        request = Net::HTTP::Delete.new(uri.path)
        result = http.request(request)
    
  3. Cryptic errors are usually caused by coding mistakes in bin/sinatra that cause Sinatra's startup to abort. To check this behaviour, try to start Sinatra manually:

    bundle exec bin/sinatra
    

Final Deployment

You now have a functional Ruby statistics library, a command line application, and web services to compute statistics and store arrays of numbers. Moreover, the code is fully covered by automated tests. Your server-side production code is complete, except for some professional touches you will make in the next exercise. All you need now is a client application to make good use of your web services.

Execute the following to repackage and deploy StatOwl as a gem:

rake clobber
rake deploy

Try these commands:

statowl help
statowl help all
echo '1,2,3,4' | statowl all
echo '1,2,3,4' | statowl -f xml all
echo '1,2,3,4' | statowl -f json all
sinatra

While Sinatra is running, try this in your browser:

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

Cleanup

Completely uninstall the gem so that it does not conflict with further development. Answer "Y" to the prompt:

gem uninstall statowl

Exercise Summary

In this exercise you used the mongo RubyGem to implement the Create, Read, Update, and Delete operations of a CRUD application. You then wrapped a RESTful web service around CRUD using the HTTP operations POST, GET, and DELETE. (In this case PUT is unnecessary, because it would accomplish the same thing as POST.)

You used RSpec Behavior Driven Development for the MongoDB production code and Cucumber acceptance tests for the web service.

In the next exercise you will learn how to manage large projects.