Duck-punching RSpec for Fun and Profit

Several times now we have found ourselves writing specs with a lot of context blocks to describe various scearios under which a piece of code might be exected. The prototypical example is a controller spec:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# spec/controllers/projects_controller_spec.rb

describe ProjectsController do
  let(:project)    { Fabricate(:project) }
  let(:project_id) { project.id }
  let(:user)       { Fabricate(:confirmed_user) }

  describe '#show' do
    subject { get :show, id: project_id, format: :json; response }

    context "when a user is logged in" do
      before { login user }

      context "and the project exists" do
        context "and the project belongs to the user" do
          before { project.user = user; project.save }

          its(:status) { should eq 200 }
        end

        context "otherwise" do
          its(:status) { should eq 404 }
        end
      end

      context "otherwise" do
        let(:project_id) { MicroToken.generate }

        its(:status) { should eq 404 }
      end
    end

    context "otherwise" do
      context "and the project exists" do
        context "and the project belongs to someone" do
          before { project.user = user; project.save }

          its(:status) { should eq 404 }
        end

        context "otherwise" do
          its(:status) { should eq 200 }
        end
      end

      context "otherwise" do
        let(:project_id) { MicroToken.generate }

        its(:status) { should eq 404 }
      end
    end
  end
end

There are two main decisions - is there a logged in user? If so then we apply a different criteria for locating and returning the requested resource.

This is a reasonably clear specification, however the repeated use of context hinders readability. Don’t be afraid to patch RSpec with a few extra context methods to improve readability:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# spec/support/context_helpers.rb

module ContextHelpers
  def When(msg,&block)
    context("When#{left_pad msg}", &block)
  end

  def Otherwise(msg,&block)
    context("Otherwise#{left_pad msg}", &block)
  end

  def And(msg,&block)
    context("And#{left_pad msg}", &block)
  end

  private

  def left_pad(msg)
    msg ? " #{msg}" : ""
  end
end
1
2
3
4
5
6
7
# spec/spec_helper.rb

Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f}

RSpec.configure do |config|
  config.extend ContextHelpers
end

With these helpers in place we can now drastically improve readability of our tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# spec/controllers/projects_controller_spec.rb

describe ProjectsController do
  let(:project)    { Fabricate(:project) }
  let(:project_id) { project.id }
  let(:user)       { Fabricate(:confirmed_user) }

  describe '#show' do
    subject { get :show, id: project_id, format: :json; response }

    When "a user is logged in" do
      before { login user }

      And "the project exists" do
        And "the project belongs to the user" do
          before { project.user = user; project.save }

          its(:status) { should eq 200 }
        end

        Otherwise do
          its(:status) { should eq 404 }
        end
      end

      Otherwise do
        let(:project_id) { MicroToken.generate }

        its(:status) { should eq 404 }
      end
    end

    Otherwise do
      And "the project exists" do
        And "the project belongs to someone" do
          before { project.user = user; project.save }

          its(:status) { should eq 404 }
        end

        Otherwise do
          its(:status) { should eq 200 }
        end
      end

      Otherwise do
        let(:project_id) { MicroToken.generate }

        its(:status) { should eq 404 }
      end
    end
  end
end

Now, we know that not everyone is going to be super pleased with this idea. We think we are probably expressing a deep preference for BDD-style testing. Regardless, we am keen to hear feedback about this style of testing from both Rubyists and non-Rubyists alike.

Comments