Jake Scruggs

writes ruby/wears crazy shirts

I'm going to try and turn my recent presentation on RSpec into a series of blog posts -- This being the first. Before I get on with it I would like to thank the Houston Ruby and Rails Users Group for having me. They asked some pretty insightful questions and were generally a great audience. I'd also like to thank ThoughtWorks for paying for my flight, rental car, and lost billable hours so I could do this presentation.

RSpec without Rails

So in order to show you how RSpec works, I'm going to start with specifying a class that deals with string case like so:


require File.dirname(__FILE__) + '/../spec_helper'
require 'string_caser'

describe StringCaser do
it "should upcase a string" do
caser = StringCaser.new("A String")
caser.upcase.should == "A STRING"
end
end

After requiring a spec_helper and the file I intend to spec, I set up a 'describe' block. Inside the block there is a single spec (or example) which is also a block. (If you've looked at RSpec before you might wonder what happened to context and specify -- they've been replaced by 'describe' and 'it' for more explanation see Describe it with RSpec) In the example I declare a the name of the spec to be "should upcase a string" which does not execute and could be any name you like. By convention they all start with "should." Then I create a new StringCaser and give it a string. On the next line I call caser.upcase which should equal "A STRING". Instead of having to remember which is expected and which is actual, the RSpec way is to simply do what you want and then call 'should' on the output. Much more readable. If you're wondering, should and should_not can be called on any Ruby Object.

So If I run the above spec without creating the file, I'll get an error on the require. So let's create a file called string_caser_spec.rb

class StringCaser
def initialize(content)
@content = content
end

def upcase
@content
end
end

And if run the spec like so:

spec string_caser_spec

I get:
F

1)
'StringCaser should upcase a string' FAILED
expected "A STRING", got "A String" (using ==)
./spec/models/string_caser_spec.rb:7:

Finished in 0.029125 seconds

1 example, 1 failure

Because I forgot to upcase the return value. Oh well, easily fixed:

def upcase
@content.upcase
end

Now run rspec so that it formats the output like so:

spec string_caser_spec -fs

And I get this output:

StringCaser
- should upcase a string

Finished in 0.036034 seconds

1 example, 0 failures

Lets add another right below the previous spec:

it "should know if a sting is all lowercase" do
caser = StringCaser.new("a lowercase string")
caser.should be_lower_case
end

Now I'm saying that a StringCaser instance should know if its content is all lowercase. But this time I'm using RSpec's built in support of interrogatives. When RSpec sees 'be_' in a matcher (which is the thing that comes after should), it looks for a method with a name that follows 'be_' and has a '?' at the end. In this case 'be_lower_case' makes RSpec look for a method called 'lower_case?' and calls it. There a lot of nice syntactic sugar like this in RSpec -- way more than I could ever hope to cover. Now 'lower_case?' doesn't exist yet so I get an error, and then I write this code:

def lower_case?
@content.downcase == @content
end

And I'm back to passing. Btw, you can call rspec with -c to print your output in color, if you like.

Now for something a little more interesting. I want StringCaser to throw a RuntimeError if it receives an object that does not respond to 'upcase.' Here's the spec:

[1, 3.4, Object.new ].each do |bad_thing|
it "should raise a Runtime Error when passed a #{bad_thing.class} because it doesn't respond to 'upcase'" do
lambda { StringCaser.new(bad_thing) }.should raise_error(RuntimeError)
end
end

Cool, huh? Sure I could do this in Test::Unit, but it would have to be one test with a generic name. In RSpec I can name on the fly (yes I could use some Ruby magic to define methods at runtime in Test::Unit, but think of the readability! (or lack there of)).

So now I add some code to StringCaser:

def initialize(content)
raise RuntimeError unless content.respond_to?(:upcase)
@content = content
end

and when I run my specs with spec string_caser_spec -fs, I get:

StringCaser
- should upcase a string
- should know if a sting is all lowercase
- should raise a Runtime Error when passed a Fixnum because it doesn't respond to 'upcase'
- should raise a Runtime Error when passed a Float because it doesn't respond to 'upcase'
- should raise a Runtime Error when passed a Object because it doesn't respond to 'upcase'

Finished in 0.035137 seconds

5 examples, 0 failures

Which is nice. There's also a way to get this output as an html file.

Now I find that as I'm writing unit tests that I often end up with a bunch of different setups, or one big complicated setup that only 1/5 of which is important to any one test. Either case kinda sucks, but RSpec gives me a way to segregate my setups like so:

describe "StringCaser with all lower case contents" do
before(:each) do
@caser = StringCaser.new("lowercase")
end

it "should return true when lower_case? is called on it" do
@caser.should be_lower_case
end

#More specs for this case
end

describe "StringCaser with some upper case contents" do
before(:each) do
@caser = StringCaser.new("MiXcAsE")
end

it "should return false when lower_case? is called on it" do
@caser.should_not be_lower_case
end

#More specs for this case
end


Now I can I have a place where I specify for each context. This tends to drive out more specs and produces a more complete suite. Warning, this does not mean I'm endorsing huge setup methods. A spec should be readable in and of itself. And yet, I think a short setup that does exactly what the 'describe' says is a useful thing.

Next time I'll talk about testing/specing/examplifying Rails Models.

2 comments :

Anonymous said...

Thanks for the article. The example with RuntimeError was really useful.

vovayartsev said...

I believe in the sencence: "So let's create a file called string_caser_spec.rb" you actually meant to say"string_caser.rb" - this file is required in the example above.

Thanks for a great article once again!