Monday, September 19, 2011

RSpec Lessons Learned

Previously I showed some unit tests written in RSpec. Those test were fairly ugly, partially because I didn't really understand RSpec. In an effort to have better unit tests, I have learned a little more about RSpec.

Read the Latest Documentation
You'd think this would be obvious, but given the lack of links to the documentation on the various "here's advice on using RSpec" blogs, it must not be done a lot. Worse, somehow I had been only looking at the old documentation for version 1 of RSpec. So, if you are going to use RSpec, I suggest you read latest documentation, which as of when I am writing this is RSpec 2.6. This will definitely be helpful.

Make Tests Self Documenting
Each test (it or specify block) can take a description. Sometimes this is necessary, but any time you have documentation (and that's what this description is), you risk having the documentation be out of sync with the code. While not everyone agrees, I am a fan of letting the tests document themselves, whenever possible.

Make Use of Context
Use the "context" keyword to describe what you are doing in a "before" block to set up the state necessary for testing.
describe Thing do
  let(:thing) {Thing.new}
  subject {thing}
  context "empty object" do
    #insert tests
  end
  context "with inherited object" do
    let(:base) {Thing.new}
    before(:each) do
      base["foo"] = 5
      thing.prototype = base
      thing["baz"] = 15
    end
    #insert tests
  end
end
Make Use of Describe
Use the "describe" keyword to describe either the noun that is being tested, or the actions that are under test. i.e. if you have actions in a "before" block that are the actions being tested, you should you use "describe" rather than "context"
describe Thing do
  let(:thing) {Thing.new}
  subject {thing}
  context "empty object" do
    describe "when assigning via properties" do
      before(:each) do
        thing["foo"] = 5
        thing["bar"] = "hello"
      end
      # insert checks
    end
  end
end
Make Use of Subject
If you have multiple tests on a single object, make it the RSpec Subject and put it in a describes block. This way, all of your "should" comparisons will be implicitly on this object.
describe Thing do
  let(:thing) {Thing.new}
  subject {thing}
  context "empty object" do
    describe "keys" do
      subject {thing.keys}
      it {should_not be_nil}
      it {should be_empty}
    end
  end
end
Use Its
Often times you want to test the properties of an object. You can use the "its" method to have the implicit subject of "should" comparisons be the result of the method or array dereference specified.
describe Thing do
  let(:thing) {Thing.new}
  subject {thing}
  context "empty object" do
    its(['newProperty']) {should be_nil}
    its('newMethod') {should be_nil}
  end
end
Let and Subject are Lazy Loaded
The "variables" defined in "let" calls and the "subject" aren't actually evaluated until they are used. So if you never reference a variable specified in a "let", then that code is never executed. This also means that order isn't important.  i.e. the following will work:
let (:a) {b + 1}
let (:b) {5}
specify("show using let") {a.should == (b+1)}
Shared_examples and shared_contexts are Global
I actually haven't found this documented, so I may be doing something wrong. But I have found that if I have two different rspec files that each have a "shared_examples_for 'test this object'", this causes problems. I can test each rspec file in isolation and it is fine. But if I try to test both at the same time, I get an error saying that a shared example already exists with the name "test this object".

There are two different solutions to this problem that I have found. If the shared examples are the same, pull them into a common Module that is included. Note, this is better than having repeated code anyway. If the examples are different, then you have to be more unique with the names of the shared examples.

Final Thoughts
It seems that the approach of RSpec is to write tests such that they are self descriptive and so that each test tests exactly one thing. While in theory, this sounds good, I am finding that this results in very verbose test files. That's even with the cleaning up that I have done after learning RSpec better.  i.e. I like a lot of what RSpec does, but I am not convinced that it is the best way to write unit tests.

Rewritten Tests
Using what I have learned, I have rewritten the unit tests from before. Here is what they look like now:

require 'spec_helper'

describe Thing do
  let(:thing) {Thing.new}
  subject {thing}

  shared_examples_for "simple object" do |map, self_keys|
    describe "keys" do
      subject {thing.keys}
      it {should have(map.size).items}
      it {should include(*(map.keys))}
      it {should_not include("noSuchProperty")}
    end
    describe "self_keys" do
      subject {thing.self_keys}
      before(:each) { self_keys ||= map.keys}
      it {should have(self_keys.size).items}
      it {should include(*self_keys)}
      it {should_not include("noSuchProperty")}
    end
    describe "fields" do
      map.each do |k, v|
        its([k]) {should == v}
      end
      its(['noSuchProperty']) {should be_nil}
    end
    describe "methods" do
      map.each do |k, v|
        its(k) {should == v}
      end
      its('noSuchMethod') {should be_nil}
    end
    describe "to_hash" do
      subject {thing.to_hash}
      it {should_not be_nil}
      it {should have(map.size).items}
      it {should include(*map.keys)}
      it {should include(map)}
    end
  end
  
  context "empty object" do
    describe "keys" do
      subject {thing.keys}
      it {should_not be_nil}
      it {should be_empty}
    end
    its(['newProperty']) {should be_nil}
    its('newMethod') {should be_nil}
    describe "to_hash" do
      subject {thing.to_hash}
      it {should_not be_nil}
      it {should have(0).items}
    end
    
    describe "when assigning via properties" do
      before(:each) do
        thing["foo"] = 5
        thing["bar"] = "hello"
      end
      it_should_behave_like "simple object", 'foo'=>5, 'bar'=>"hello"
    end
    
    describe "when assigning via methods" do
      before(:each) do
        thing.foo = 5
        thing.bar = "hello"
      end
      it_should_behave_like "simple object", 'foo'=>5, 'bar'=>"hello"
    end
  end
  
  context "with inherited object" do
    let(:base) {Thing.new}
    before(:each) do
      base["foo"] = 5
      base["bar"] = "hello"
      thing.prototype = base
      thing["baz"] = 15
      thing["bye"] = "bye"
    end
    it_should_behave_like "simple object", 
      {'foo'=>5, 'bar'=>"hello", 'baz'=>15, 'bye'=>"bye"}, ['baz', 'bye']
    describe "when overriding values" do
      before(:each) do
        thing["foo"] = 25
        thing["bar"] = "hola"
      end
      it_should_behave_like "simple object", 'foo'=>25, 'bar'=>"hola", 'baz'=>15, 'bye'=>"bye"
    end
  end
end

Below is what the output looks like. As you can see, if you read it, it describes the tests that are being run more clearly than the old version of the tests.

$ rspec spec/models/thing_spec.rb 

Thing
  empty object
    keys
      should not be nil
      should be empty
    ["newProperty"]
      should be nil
    newMethod
      should be nil
    to_hash
      should not be nil
      should have 0 items
    when assigning via properties
      it should behave like simple object
        keys
          should have 2 items
          should include "foo" and "bar"
          should not include "noSuchProperty"
        self_keys
          should have 2 items
          should include "foo" and "bar"
          should not include "noSuchProperty"
        fields
          ["foo"]
            should == 5
          ["bar"]
            should == "hello"
          ["noSuchProperty"]
            should be nil
        methods
          foo
            should == 5
          bar
            should == "hello"
          noSuchMethod
            should be nil
        to_hash
          should not be nil
          should have 2 items
          should include "foo" and "bar"
          should include {"foo"=>5, "bar"=>"hello"}
    when assigning via methods
      it should behave like simple object
        keys
          should have 2 items
          should include "foo" and "bar"
          should not include "noSuchProperty"
        self_keys
          should have 2 items
          should include "foo" and "bar"
          should not include "noSuchProperty"
        fields
          ["foo"]
            should == 5
          ["bar"]
            should == "hello"
          ["noSuchProperty"]
            should be nil
        methods
          foo
            should == 5
          bar
            should == "hello"
          noSuchMethod
            should be nil
        to_hash
          should not be nil
          should have 2 items
          should include "foo" and "bar"
          should include {"foo"=>5, "bar"=>"hello"}
  with inherited object
    it should behave like simple object
      keys
        should have 4 items
        should include "foo", "bar", "baz", and "bye"
        should not include "noSuchProperty"
      self_keys
        should have 2 items
        should include "baz" and "bye"
        should not include "noSuchProperty"
      fields
        ["foo"]
          should == 5
        ["bar"]
          should == "hello"
        ["baz"]
          should == 15
        ["bye"]
          should == "bye"
        ["noSuchProperty"]
          should be nil
      methods
        foo
          should == 5
        bar
          should == "hello"
        baz
          should == 15
        bye
          should == "bye"
        noSuchMethod
          should be nil
      to_hash
        should not be nil
        should have 4 items
        should include "foo", "bar", "baz", and "bye"
        should include {"foo"=>5, "bar"=>"hello", "baz"=>15, "bye"=>"bye"}
    when overriding values
      it should behave like simple object
        keys
          should have 4 items
          should include "foo", "bar", "baz", and "bye"
          should not include "noSuchProperty"
        self_keys
          should have 4 items
          should include "foo", "bar", "baz", and "bye"
          should not include "noSuchProperty"
        fields
          ["foo"]
            should == 25
          ["bar"]
            should == "hola"
          ["baz"]
            should == 15
          ["bye"]
            should == "bye"
          ["noSuchProperty"]
            should be nil
        methods
          foo
            should == 25
          bar
            should == "hola"
          baz
            should == 15
          bye
            should == "bye"
          noSuchMethod
            should be nil
        to_hash
          should not be nil
          should have 4 items
          should include "foo", "bar", "baz", and "bye"
          should include {"foo"=>25, "bar"=>"hola", "baz"=>15, "bye"=>"bye"}

Finished in 0.0831 seconds
78 examples, 0 failures

No comments: