Ruby Behavior Driven Development Tutorial

Exercise 2: Behavior Driven Development

by Richard Kuehnel


"Behavior Driven Development is about implementing an application by describing its behavior from the perspective of its stakeholders." - David Chelimsky

This exercise covers the following topics.

  1. the basics of Behavior Driven Development
  2. the three laws of Test Driven Development
  3. using RSpec for unit testing
  4. writing extensions to core Ruby classes
  5. using triangulation to keep test cycles short
  6. Ruby error handling
  7. in-file testing for small applications
  8. tips and tricks for unit testing

Behavior Driven Development

Ruby programming thrives on tests and they should be used for any project destined for public distribution. They create an automated safety net, enable problem discovery, allow for aggressive refactoring, and support interactive design.

The label "Behavior Driven Development" was created to better explain the purpose of Test Driven Development. BDD focuses on an object's behavior instead of its internal structure. At the application level, for example, stakeholders are not concerned with the implementation of a database. They want to be assured that the data is being stored and that they can retrieve it in a convenient way. In other words, they are concerned with behavior more than implementation.

Behavior Driven Development gives software developers and business analysts a shared set of tools and processes to collaborate on software development. Whether you prefer to use the term "Behavior Driven Development" or "Test Driven Development," the important point is that stakeholder requirements create tests and tests guide the development of production code.

The 3 Laws of Test Driven Development

A basic test cycle consists of three steps: writing a failing test, writing the smallest amount of production code to make the test pass, and then refactoring both test and production code without adding any new functionality. Here are the Three Laws of Test Driven Development according to Robert C. Martin ("Uncle Bob"):

  1. You may not write production code until you have written a failing unit test.
  2. You may not write more of a unit test than is sufficient to fail, and not compiling is failing.
  3. You may not write more production code than is sufficient to pass the current failing test.

The second law is often invoked when you create a new class or a new method. When the test is written first, the compiler complains that the class or method does not exist. In a verbose language like Java, the rule implies that a test create only an instance of the new class or invoke the new method before proceeding with another test for class or method functionality.

Ruby is a bit different, because so much more can happen in a single line of test code. Creating a new instance, invoking a method on it, and using the result often happen in a single line of code. The intent of these rules, however, is to write a simple failing test, then pass the test in the simplest way possible.

A Behavior Driven Development Cycle

"Start by writing the code you wish you had." - James Grenning

A Behavior Driven Development cycle consists of three steps: write a failing test, write production code to make it pass, and refactor code by improving it without adding any new functionality. This is often referred to as "test-code-refactor" or "red-green-refactor."

RSpec tests are executable examples of expected behavior. In the RSpec test below, the it method creates an example of the behavior of the Array input. It states that if input equals [1,2,3,4,5] and you invoke its mean method, then you should get 3.0 as a result. In other words, this test "code" does what it clearly says it does. Create a subdirectory spec in your statowl working directory and insert the following into spec/statowl_spec.rb.

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

describe 'StatOwl statistics' do

  describe 'the array [1,2,3,4,5]' do
    input = [1,2,3,4,5]
    it 'returns a mean of 3.0' do
      expect(input.mean).to eq(3.0)
    end
  end

end

As you know, Ruby arrays don't have this behavior. In fact, they don't even have a mean method. In the spirit of BDD you are testing for behavior that does not yet exist.

Execute rake spec.

What file fails to load?
lib/statowl/extensions.rb
  

Create the file lib/statowl/extensions.rb without any content:

touch lib/statowl/extensions.rb

The new file loads and the test runs. It fails, however, because Ruby arrays don't have a method called mean. Correct this by adding the empty method to lib/statowl/extensions.rb:

class Array
  def mean
  end
end

Run rake spec again and observe that you have a failing test. Write the minimum amount of code in lib/statowl/extensions.rb needed to pass the test.

class Array
  def mean
    3.0
  end
end
You wrote a failing test. Then you wrote production code to make it pass. What is next?
Refactor!
  

The test code and production code look clean, so you can skip the refactoring step and procede with the next test-code-refactor cycle.

Cycle Two

Now that the test passes, use triangulation to rule out a trivial, hard-coded answer by adding an additional test to spec/statowl.spec

describe 'StatOwl statistics' do
...

  describe 'the array [6,7,8,9,10]' do
    input = [6,7,8,9,10]
    it 'returns a mean of 8.0' do
      expect(input.mean).to eq(8.0)
    end
  end

...

Execute rake spec to ensure that the new test fails. Your trivial solution is now broken, so fully implement the method in lib/statowl/extensions.rb to return the mean (average) of the Array.

  def mean
    reduce(:+) / size.to_f
  end

Rubyists probably use the Enumerable method inject more than its synonym reduce, but some prefer the latter, especially the Hadoop community, because it complements the map method.

Your mean method is a single line of code, which occurs often in Ruby. The name mean is rather scientific, however. Many users would probably look for a method to compute an average instead. How, using a single line of code, could you add an average method that does the same thing as mean?
class Array

  ...

  alias :average :mean

end
  
Should you add this line of code to lib/statowl/extensions.rb?
No! You would be adding additional functionality to your production code.
Instead, write another test for spec/statowl_spec.rb:

describe 'StatOwl Statistics' do ... describe 'the Array [-2,-2,2,2]' do input = [-2,-2,2,2] it 'returns an average of 0.0' do expect(input.average).to eq(0.0) end end ...
Run the test to ensure that it fails. Then make it pass.

Refactor? Come to think of it, reduce(:+) in your mean method is rather cryptic. It just adds up all the numbers in an array. Gee, isn't this what most people would call a sum? Refactor your production code by making this a separate, private method.

class Array

...

  def mean
    sum / size.to_f
  end

...

  private

  def sum
    reduce(:+)
  end

end

This is much more readable. First, the mean is just the sum divided by the size. Second, you don't need to consult the Ruby API to know what reduce(:+) is doing. Make your code read like you would describe it to someone. "Reduce to a single number by invoking the binary operation + sounds like a computer. "Take the sum and divide by the size" sounds more like a human being.

Since sum is private there is no need to test it separately. Are your existing tests still green?

Cycle Three

In statistics the median is the numerical value separating the higher half of a set of numbers from the lower half. In other words, if the median is selected, then half the remaining numbers are higher and half are lower. The median of 1,2,3,4,5 is 3. The median of 1,2,3,99,100 is also 3.

Add another test to spec/staowl_spec.rb for calculating the median.

...

  describe 'the array [1,2,3,99,100]' do
    input = [1,2,3,99,100]
    it 'returns a median of 3.0' do
      expect(input.median).to eq(3.0)
    end
  end

...
Execute rake spec and correct the resulting error with an empty method. Then make sure the test executes but fails. What is the simplest way to make the test pass?
class Array
...

  def median
    3.0
  end

...
end
  

Implement this solution and ensure that it passes.

Refactor?

Cycle Four

Use triangulation again to keep the trivial solution from passing.

describe 'StatOwl statistics' do
...

  describe 'the array [6,7,8,999,1000]' do
    input = [6,7,8,999,1000]
    it 'returns a median of 8.0' do
      expect(input.median).to eq(8.0)
    end
  end

...
end

Ensure that it fails and then write some production code to make it pass.

class Array
...

  def median
    middle = size / 2
    sort[middle].to_f
  end

...
end

All green? Refactor? Ready to move on to computing statistical variance?

Not so fast. The solution you implemented picked the middle number from a sorted array of numbers. What if the size of the array is even? By convention, the median is then the average of the middle two numbers.

Cycle Five

You need to add another test to reveal the problem. Add a new context with a new test.

describe 'StatOwl statistics' do
...

  describe 'the array [6,7,8,999]' do
    input = [6,7,8,999]
    it 'returns a median of 7.5' do
      expect(input.median).to eq(7.5)
    end
  end

...
end

Failing test? Modify your production code to make it pass.

class Array
...
  def median
    middle = size / 2
    return sort[middle].to_f if size.odd?
    0.5 * (sort[middle -1] + sort[middle])
  end
...
end

Refactor?

Cycle Six

Currently an expression like [1,2,3] < [2,3,4] is undefined because there is no < method for the Array class. Add a description and new tests to make arrays comparable based on their means.

describe 'StatOwl statistics' do
...

  describe '[1,2,3,4] when compared based on the mean' do
    input = [1,2,3,4]
    it 'is less than [2,3,4,5]' do
      expect(input < [2,3,4,5]).to eq(true)
      expect(input > [2,3,4,5]).to eq(false)
    end
    it 'is greater than [0,1,2,3]' do
      expect(input < [0,1,2,3]).to eq(false)
      expect(input > [0,1,2,3]).to eq(true)
    end
  end

...
end

Are the new tests failing as planned? Why? Write the minimum amount of code needed to eliminate the NoMethodError error. Verify that your error clears but the tests still fail. Then modify your production code to make them pass.

class Array
...

  def <(other_array)
    mean < other_array.mean
  end

  def >(other_array)
    mean > other_array.mean
  end

...
end

More Cycles

Add variance to your statistical methods. Just like before, add a new RSpec test, implement a trivial solution and then use triangulation to pin down the design.

Hint 1: Variance is a measure of how much, on average, the numbers in an array are spread out higher and lower than the mean. For example, both [4.8,4.9,5.1,5.2] and [3,4,6,7] have a mean of 5.0 but the second array is more spread out. Their variances are 0.025 and 2.5, respectively. To compute the variance you
  1. Compute the mean
  2. Subtract the mean from each element in the array
  3. Square each element in the array
  4. Sum the resulting squared values
  5. Divide the sum by the number of elements in the array
Hint 2: Floating point results can be tested to an arbitrary precision. For example, instead of
    expect(input.variance).to eq(1.4142135623730951)

you can use this instead:

    expect(input.variance).to be_within(0.001).of(1.414)

Error Handling

If you attempt to compute statistics on an empty array you currently get a rather cryptic message that refers to the class NilClass. Error messages should explicitly describe the cause of the error. The extensions you are writing can be performed on any Array, including []. Since it is possible that [].mean may occur, create a new type of exception that you will call an EmptyArrayException. Any added functionality, including improvements to error handling, require a test, so write one for the new exception.

describe 'StatOwl statistics' do
...

  describe 'An empty array' do
    it 'should throw an exception when computing statistics on an empty array' do
      message = 'computing statistics on an empty array'
      expect {[].mean}.to raise_exception(EmptyArrayException, message)
    end
  end

...
end

Write the minimum amount of code to correct the error due to EmptyArrayException being undefined.


class EmptyArrayException < Exception
end

class Array
...
end

Check to make sure the immediate error is eliminated and the test merely fails. Then make it pass.

  def mean
    verify_not_empty
...
  end
...
  private

  def verify_not_empty
    if size == 0
      raise EmptyArrayException, 'computing statistics on an empty array'
    end
  end

...

All green? Notice how readable the RSpec output is. This is an important objective of Behavior Driven Development. Tests should correlate with the language of the business domain. verify_not_empty now works for the mean method. Add it to the top of the median, variance, <, and > methods. Refactor?

Tips and Tricks

Here are some important guidelines for writing unit tests.

  1. Test cases should verify only one simple behavior. If you have several behaviors, write several tests.
  2. Test that exceptions are being thrown when required and not being thrown unexpectedly.
  3. A design that appears to be difficult to test may be a design that requires change.

As a final tip, consider what you could do if you were tasked only to write a simple script, not a command line app, web service, or anything more complex. Do you need to configure a Rakefile and a complex directory structure to create automated tests? Absolutely not!

As a concrete example, create a new file statistics.rb somewhere outside the StatOwl project and insert the following into it.

class Array

  def mean
    reduce(:+) / size.to_f
  end

  def median
    middle = size / 2
    return sort[middle].to_f if size.odd?
    0.5 * (sort[middle -1] + sort[middle])
  end

  def variance
    map{ |number| (number - mean) ** 4 }.mean
  end

  # Is this file being tested via the command "rspec MY_RUBY_FILENAME"???
  if __FILE__.gsub(/.*\//,"") == ARGV[0]

    require 'rspec'

    describe 'the array [1,2,3,4,5]' do
      input = [1,2,3,4,5]
      it 'returns a mean of 3.0' do
        expect(input.mean).to eq(3.0)
      end
      it 'returns a median of 3.0' do
        expect(input.median).to eq(3.0)
      end
      it 'returns a variance of 2.0' do
        expect(input.variance).to eq(2.0)
      end
    end
  end
end

Because of the if statement surrounding the tests, if the file is included as a library for another project then the tests are skipped. If, however, you execute rspec statistics.rb, then your tests will run. Try it out!

Whoops! The variance method is not working well. Good thing you included automated testing. Change the 4 to a 2 and check that all tests pass.

Exercise Summary

In this exercise you learned that Behavior Driven Development adheres to the belief that what an object does is more important that what it is. It provides software developers and business analysts with shared tools to collaborate on software development. You also got a lot of BDD practice using RSpec's test-code-refactor mantra and you followed the three laws of test driven development:

  1. You may not write production code until you have a failing unit test.
  2. You may not write more of a unit test that is sufficient to fail.
  3. You may not write more production code than is sufficient to pass the current failing test.

You also learned how to create extensions to core Ruby classes and the convention of allowing them only in an extensions.rb file or in an extensions directory. Place them anywhere else and you will experience the wrath of developers!

Finally, you learned how to test exceptions and how to test short scripts outside of a RubyGem project.

In the next exercise you will learn about acceptance testing.