Ruby Behavior Driven Development Tutorial

Exercise 11: Managing Large Projects

by Richard Kuehnel

This exercise covers the following topics.

  1. configuration management using SettingsLogic
  2. organizing complex Rake tasks
  3. using test doubles for enterprise projects

Configuration Management

Take a look at bin/sinatra. From a configuration management perspective, what problems do you see with the MongoDB setup?
The host, port, and database names are hardcoded.
Changing their values requires modifying source code.
  

YAML is a human-readable data serialization format. It serves the same purpose as XML but is much less tedious to write. It stands for "YAML Ain't Markup Language," which emphasizes its focus on data serialization, not document markup.

SettingsLogic is a RubyGem that reads a YAML file containing configuration settings. Add settingslogic as a runtime dependency to statowl.gemspec.

In your bin/sinatra file, create a new class to handle your settings and point it to the location of the YAML configuration file that you will create next:

...
require 'settingslogic'

class Settings < Settingslogic
  source Dir.pwd + '/application.yml'
end
...

Insert the MongoDB settings into a new YAML file application.yml in your project root directory:

mongo_server: 'localhost'
mongo_port: '27017'
mongo_database: 'statowl_database'

Finally, remove the hardcoded values in bin/sinatra and replace them with the constants specified in application.yml:

...

  def connect_to_database
    config = Settings.new
    mongo_URL = 'mongodb://' + config.mongo_server + ':' + config.mongo_port + '/' + config.mongo_database
    mongo_client = Mongo::Client.new(mongo_URL)
    collection = mongo_client[:statowl_stored_numbers]
    repository = Statowl::MongoRepository.new(collection)
    yield repository
  end

...

Run Cucumber - still green? You now have a central location for paths, names, URLs, ports, and any other configuration settings you project needs.

Complex Rake Tasks

Large projects often have complex Rake tasks. Cleaning out a MongoDB database, for example, is more complex than cleaning out a file directory. Extracting a task file like this from the Rakefile reduces clutter and makes the task independently testable.

Create a new unit test file spec/mongo_clean_spec.rb and insert the following into it:

require 'settingslogic'
require_relative '../lib/statowl'
require_relative '../lib/statowl/mongo_cleaner'

class Settings < Settingslogic
  source Dir.pwd + '/application.yml'
end

describe 'A MongoDB database cleaner' do

  def empty?(collection)
    collection.find({}).count == 0
  end

  it 'should clean the database' do
    config = Settings.new
    mongo_URL = 'mongodb://' + config.mongo_server + ':' + config.mongo_port + '/' + config.mongo_database
    mongo_client = Mongo::Client.new(mongo_URL)
    collection = mongo_client[:statowl_stored_numbers]
    collection.insert_one({ "key" => "my_key", "value" => "my_value" })
    expect(empty?(collection)).to be(false)
    cleaner = Statowl::MongoCleaner.new(collection)
    cleaner.clean
    expect(empty?(collection)).to be(true)
  end

end

Execute rake spec to check that it fails and then create a new file lib/statowl/mongo_cleaner.rb to make it pass:

module Statowl

  class MongoCleaner

    def initialize(collection)
      @collection = collection
    end

    def clean
      @collection.find({}).delete_many
      puts 'Mongo: All Clean!'
    end

  end

end

It's time to turn your well tested database cleaner into a Rake task. Add the new file to lib/statowl.rb:

require 'statowl/mongo_cleaner'

Add the new task to Rakefile:

require 'statowl'
require 'settingslogic'
require 'mongo'
...
class Settings < Settingslogic
  source Dir.pwd + '/application.yml'
end
...

namespace 'mongo' do
  task :clean do
    config = Settings.new
    mongo_URL = 'mongodb://' + config.mongo_server + ':' + config.mongo_port + '/' + config.mongo_database
    mongo_client = Mongo::Client.new(mongo_URL)
    collection = mongo_client[:statowl_stored_numbers]
    Statowl::MongoCleaner.new(collection).clean
  end
end

...

It has a mongo namespace to differentiate it from Rake's build-in clean task, so execute the new task like this:

rake mongo:clean

To automatically clean the database before running RSpec tests you need to make the spec task dependent on the mongo:clean task. The syntax is a little different than other tasks in your Rakefile because the mongo:clean task is namespaced.

In Rakefile add

task :spec => "mongo:clean"

Now you start each test run with an empty collection. Execute rake spec and you will observe "Mongo: All Clean!" at the top of the console output just before the tests are run.

Testing Enterprise Projects

Unit tests are usually for new production code that you write, not for dependencies like external databases and web services. Theoretically, if the dependencies work correctly and your code works correctly then they should all work together correctly. Integration tests prove that this assumption matches reality.

The use of test doubles for external dependencies enables unit tests to be run in isolation. Here are some of the reasons why they are used:

  1. A dependency runs too slowly.
  2. A dependency is unavailable or it has not yet been developed.
  3. A dependency is very difficult to setup and run for the test.

There are three general types of test doubles based on their sophistication:

The database operations for your StatOwl application have a critically important external dependency: MongoDB. In real life it might be running on a remote server. The extent of your tests and the slowness of the database might be causing your tests to run slowly. This could be an opportunity to create a test double.

All of your database operations rely on low-level methods of the MongoDB::Collection class: insert, find, and remove. This is an ideal boundary for a test double that allows unit tests to run in isolation from the MongoDB database.

There are popular libraries for writing Ruby test doubles, but the easiest implementation is a simple class. To the top of spec/mongo_spec.rb insert a test for a new fake of a collection that you can pass to Statowl::Mongo in lieu of a real MongoDB collection:

require_relative '../lib/statowl/mongo_repository'

#
# ToDo:
# A new Statowl::Collection class will go here.
#
describe 'a fake collection' do

  it 'should implement insert_one, find, and delete_many' do
    collection = Statowl::Collection.new
    expect(collection.find({}).size).to eq(0)
    document = { "key" => "key1", "value" => "value1" }
    collection.insert_one document
    documents = collection.find({ "key" => "key1" })
    expect(documents.any?).to eq(true)
    expect(documents.first).to eq(document)

    document = ({ "key" => "key2", "value" => "value2" })
    collection.insert_one document
    documents = collection.find({ "key" => "key2" })
    expect(documents.any?).to eq(true)
    expect(documents.first).to eq(document)

    #delete the first record
    collection.delete_many({ "key" => "key1" })
    documents = collection.find({ "key" => "key1" })
    expect(documents.any?).to eq(false)

    documents = collection.find({ "key" => "key2" })
    expect(documents.any?).to eq(true)
    expect(documents.first["value"]).to eq("value2")
  end

end


...

Make sure that it fails. Then make it pass by replacing your ToDo comments with an implementation of the Statowl::Collection class:

...

class Statowl::Documents < Array

  def any?
    self.size > 0
  end

  def first
    self[0]
  end

end

class Statowl::Collection

  def initialize
    @documents = Statowl::Documents.new
  end

  def insert_one(document)
    @documents << document
  end

  def find(query)
    results = @documents.select do |hash|
      query.empty? || hash['key'] == query['key']
    end
    Statowl::Documents.new(results)
  end

  def delete_many(query)
    trash_docs = find(query)
    @documents.delete(trash_docs.first)
  end

end

...

Now you can use your fake collection to run your RSpec tests in spec/mongo_spec.rb without MongoDB.

...

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)
  repository = Statowl::MongoRepository.new(Statowl::Collection.new)

...

Exercise Summary

In this exercise you used SettingsLogic to manage your RubyGem's configuration settings. SettingsLogic needs only a few lines of code to declare the Settings class and point it to a YAML file containing configuration settings.

You also organized a complex Rake task by defining a separate class for the details. Simplifying Rake tasks through delegation is a technique often used by well-written gems.

Finally, you wrote a test double to unit test your production code without connecting to MongoDB. There are three types of test doubles: a simple stub with hardcoded values, a more sophisticated fake that represents an alternative implementation of the interface, and a highly sophisticated mock that verifies expected collaboration with other objects.