Let me tell you why I love RSpec

Carefully balancing his tray so as not to spill the cup of soup, C01t walks slowly over to his teams lunch table. As he sets the tray down on the table he hears Kaya say: “Ian, let me tell you why I love rspec and why I look for similar test frameworks in other programming languages!” C01t wonders what this is all about. Leaning over so as to not disturb the ongoing conversation he asks Nick: “What is rspec?”. “rspec is the BDD test framework for Ruby“, replies Nick. C01t settles into his seat, as Kaya starts to lay out her argument. This is one debate he does not want to miss.

Nested test groups

rspec‘s DSL is a powerful tool for organizing test cases, aka examples. You can declare test groups with the methods describe and context. Use test groups to associate tests that verify related functionality or share the same execution context. Furthermore, you can nest test groups. Nested groups are essentially sub classes of the outer groups and provide the expected inheritance semantics. There is no limit on the depth of the nesting.

To illustrate how to best use the describe and context, let’s consider the following class:


class Froyo
  def add_toppings(toppings)
    ...
  end

  def price(coupon)
    ...
  end
end

First you define a describe with the Froyo as the parameter to identify the class under test. Then, for each method of Froyo you add a nested describe with a string containing the method name as the parameter. (Instance method names should be preceded by "#". Class method names should be preceded by ".".) Finally, we define a nested context for each relevant scenario.

You can implement tests inside any of the describe or context blocks. As a result, when reading this spec you can quickly identify what functionality and scenario is being tested. Additionally, when you need to add additional tests for any of the methods it is obvious where to insert them.

The complete spec file for the Froyo class would look something like:


describe Froyo do
  describe "#add_toppings" do
    # add scenarios and test cases
  end

  describe "#price" do
    it "returns a number >= 0" do
      # some test code
    end

    context "with no toppings" do
      it "costs $4.5" do
        # some test code
      end
    end

    context "with 2 toppings" do
      it "costs $5.0" do
        # some test code
      end

      it "cost $4.5 with free toppings coupon" do
        # some test code
      end
    end

    # more contexts
  end
end

Hierarchical before and after

Like most other test frameworks, rspec provides before and after hooks for performing setup and tear down. And similarly, you can scope the hooks to either a single test case (before(:each) and after(:each)), a group of test cases (before(:all) and after(:all)), or the entire run (before(:suite) and after(:suite)).

But what distinguishes rspec from most other test frameworks, is that you can define hooks inside any test group as well as in a rspec configure (a global configuration section). And for a given test, rspec will find and execute all applicable setup and tear down methods. Therefore, you can decompose test setup and tear down cleanly between the nested test groups.

Finally, consider the following example spec:


describe Froyo do
  before(:all) { puts "Froyo one-time setup" }
  after(:all) { puts "Froyo one-time tear down" }

  before(:each) { puts "Froyo setup" }
  after(:each) { puts "Froyo tear down" }

  describe "#price" do
    before(:all) { puts "price one-time setup" }
    after(:all) { puts "price one-time tear down" }

    before(:each) { puts "price setup" }
    after(:each) { puts "price tear down" }

    it "costs at least $0" do
      puts "costs at least $0"
    end
   
    it "validates the type of the coupon parameter" do
      puts "validates the type of the coupon parameter"
    end 

    # more scenarios and tests
  end
end

If you run the above spec file, you will get the following output:

Froyo one-time setup
price one-time setup

Froyo setup
price setup
costs at least $0
price teardown
Froyo tear down

Froyo setup
price setup
validates the type of the coupon parameter
price teardown
Froyo tear down

price one-time teardown
Froyo tear down

rspec invoked all relevant setup and tear down blocks, without blocks nested in the same group as the tests explicitly referring to blocks in the parent groups.

Memoized subject and let helpers

You can use subject and let declarations inside test groups to replace local test variables with methods whose return values are memoized. The values returned by the subject and let declarations are allocated on first use. Since the return values are memoized, the methods can be used repeatedly within a test.


describe Froyo do
  subject(:froyo) { described_class.new }
  let(:toppings) { %w[sprinkles 'gummy bears'] }

  it "costs $4.50 without toppings" do 
    expect(froyo.price).to eq(4.50)
  end

  it "costs $5.00 with 2 toppings" do
    froyo.add_toppings toppings
    expect(froyo.price).to eq(5.00)
  end
end

So the first test above does not incur the penalty of allocating the toppings array. Furthermore, the second test calls froyo twice and received the same object.

Additionally, both declarations play well with nested test groups, and before(:each) and after(:each) hooks. When executing a test or a before/after hook referencing a method declared via subject or let, rspec searches the test group hierarchy and invokes the method closest to the test.


describe Froyo do
  subject(:froyo) { described_class.new }
  let(:toppings) { [] }

  before { froyo.add_toppings toppings }

  it "costs $4.50 without toppings" do 
    expect(froyo.price).to eq(4.50)
  end

  context "with 2 toppings" do
    let(:toppings) { %w[sprinkles 'gummy bears'] }

    it "costs $5.00 with 2 toppings" do
      expect(froyo.price).to eq(5.00)
    end
  end

  # more scenarios and tests
end

First, you used froyo and toppings inside a before(:each) hook. Then you overwrote let(:toppings) inside the context "with 2 toppings". And during execution, for the test inside context "with 2 toppings" when the before hook in the parent group was executed the overridden value of toppings was used.

Finally, putting it all together you can DRY test code using subject and let:


describe Froyo do
  subject(:froyo) { described_class.new }
  let(:toppings) { [] }

  before { froyo.add_toppings toppings }

  describe "#price" do
    let(:coupon) { "FREE_TOPPINGS" }

    context "with no toppings" do
      it "costs $4.50" do
        expect(froyo.price).to eq(4.50)
      end

      it "costs $4.50 with a free toppings coupon" do
        expect(froyo.price(coupon)).to eq(4.50)
      end
    end

    context "with 2 toppings" do
      let(:toppings) { %w[sprinkles 'gummy bears'] }

      it "costs $5" do
        expect(froyo.price).to eq(5.00)
      end

      it "costs $4.50 with a free toppings coupon" do
        expect(froyo.price(coupon)).to eq(4.50)
      end
    end

    # more scenarios and tests 
  end
end

Test case reuse with shared_examples

Finally, you can use the method shared_examples to define test groups that can be nested into multiple other test groups. The shared test groups are scoped based of where they are defined. Therefore, they are available to for inclusion in the group they were defined in or child groups, but not in sibling or parent groups.

You include a shared test group to be evaluated in the context of another test group using the it_behaves_like method.

Consequently, you can use shared groups to execute a common set of tests for each scenario of the price method of the Froyo class:


describe Froyo do
  subject(:froyo) { described_class.new }
  let(:toppings) { [] }

  before { froyo.add_toppings toppings }

  describe "#price" do
    shared_examples "real price" do
      it "costs at least $0" do
        expect(froyo.price).to be >= 0.0
      end
    end

    context "with no toppings" do
      it_behaves_like "real price"
    end

    context "with 2 toppings" do
      let(:toppings) { %w[sprinkles 'gummy bears'] }

      it_behaves_like "real price"
    end

    # more scenarios and tests 
  end
end

Additional Resources

Probably the two most useful resources when using rspec are:
the official documentation and better specs. Most of all, you should review better specs before writing any tests.

In addition all the code in this post is available in our lab-ruby repository.

Leave a Reply

Your email address will not be published. Required fields are marked *