"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.
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.
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"):
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.
"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
.
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
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.
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.
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
lib/statowl/extensions.rb
?
No! You would be adding additional functionality to your production code. Instead, write another test forspec/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?
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 ...
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?
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.
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?
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
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
- Compute the mean
- Subtract the mean from each element in the array
- Square each element in the array
- Sum the resulting squared values
- 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 ofexpect(input.variance).to eq(1.4142135623730951)you can use this instead:
expect(input.variance).to be_within(0.001).of(1.414)
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?
Here are some important guidelines for writing unit tests.
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.
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:
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.
Copyright © 2005-2023 Amp Books LLC All Rights Reserved