RSpec Shared Examples and Ruby Metaprogramming

Introduction

Metaprogramming is both fun and challenging all at the same time. Metaprogramming with Ruby is easier than some other languages due to its dynamic nature. However, testing metaprogamming code can be a real challenge especially covering various edge case scenarios. In these situations, I’ve come to appreciate RSpec Shared Examples to make testing metaprogramming code easier.

For the purposes of this post we’ll start with a simple Ruby class and modify it, along with the specs, to include metaprogramming and shared example.

The starting point

Let’s start with a simple class that defines one method that returns a string. This is a trivial example, but in the next section we’ll add some metaprogramming to allow the method to be created on initialization of the class.

my_class.rb link
1
2
3
4
5
class MyClass
  def my_method
    :my_method.to_s
  end
end

Here is the corresponding example that validates the functionality.

my_class_spec.rb link
1
2
3
4
5
6
7
require 'spec_helper'

describe MyClass do
  it "returns 'my_method'" do
    MyClass.new.my_method.should eq("my_method")
  end
end

Adding the metaprogramming

Building upon the previous version of my_class, let’s enhance the code and the specs to define the method when the class is initialized. The method still returns a string that matches the method name.

my_class_dynamic.rb link
1
2
3
4
5
6
7
8
9
10
class MyClassDynamic
  DEFAULT_METHOD_NAME = :my_dynamic_method

  def initialize(method_name = nil)
    method_name = DEFAULT_METHOD_NAME unless method_name

    method_definition = Proc.new { method_name.to_s }
    self.class.send(:define_method, method_name, method_definition)
  end
end

The specs for this class get a little more challenging, because we need to handle the case when there is no method_name passed in and when there is one passed in.

my_class_dynamic_spec.rb link
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
require 'spec_helper'

describe MyClassDynamic do
  it "creates the method with the default method name" do
    MyClassDynamic.new.should respond_to MyClassDynamic::DEFAULT_METHOD_NAME
  end

  it "returns the default method name as a string" do
    MyClassDynamic.new.send(MyClassDynamic::DEFAULT_METHOD_NAME).should eq(MyClassDynamic::DEFAULT_METHOD_NAME.to_s)
  end

  it "creates the method with the :dynamic_method" do
    MyClassDynamic.new(:dynamic_method).should respond_to :dynamic_method
  end

  it "returns :dynamic_method as a string" do
    MyClassDynamic.new.send(:dynamic_method).should eq(:dynamic_method.to_s)
  end
end

These specs are pretty straight forward. The first and the third examples ensure that the correct method is created. The second and fourth examples ensure that the method returns the correct string value.

What I don’t like though is the duplication of code. For each scenario I want to test I need to add two more examples. This will only further compound as the functionality of the code grows.

Adding some shared example

In order to DRY up the specs and to allow for easily adding other scenarios to test, I’m going to implement RSpec Shared Examples.

my_class_shared_spec.rb link
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
require 'spec_helper'

shared_examples "a dynamic my class" do |method_name = nil|
  let(:address) { method_name.nil? ? MyClassDynamic.new : MyClassDynamic.new(method_name) }

  it "creates the method" do
    address.should respond_to method_name
  end

  it "returns the method name as a string" do
    address.send(method_name).should eq(method_name.to_s)
  end
end

describe MyClassDynamic do
  it_behaves_like "a dynamic my class", MyClassDynamic::DEFAULT_METHOD_NAME
  it_behaves_like "a dynamic my class", :my_dynamic_method
  it_behaves_like "a dynamic my class", :your_dynamic_method
end

You can see in this example that the specs no longer repeat in the specs. All that I have to do is to call it_behaves_like "a dynamic my class" for each scenario that I want to test. I also added a third scenario that tests the method name of :your_dynamic_method with one line of code, demonstrating how easily we can add another scenario.

By passing the method_name into the shared example, we can use the method_name parameter to ensure that the examples can test a variety of scenarios.

Normally I wouldn’t include the shared examples in the same file as the specs. I would move these to a support/my_class_shared_examples.rb file and require that file in the rspec_helper.rb file or the individual spec files themselves to keep the spec code tidy.

Conclusion

By using shared examples as described above, not only is your code DRY, but there are a couple of added benefits. First, adding additional scenarios to test requires adding one line of code to your existing specs. Second, each time that you add an example to the shared example, you can be confident that it works in all scenarios.

When I wanted to change the configuration in my CanBe gem to allow anyone using the gem to pass in the details association name, I turned to shared examples to ensure that the metaprogamming required is working properly. It greatly reduced the time to implement this functionality and ensured that it was working correctly without breaking existing functionality.

All of the code for this example can be found in my rspec_shared_example_post repo on GitHub.

Comments
« Defining your customers jQuery jerky slideUp and slideDown with Twitter Bootstrap Well »

Comments