Intro

I want to share my thoughts about a specific, low-level duplication problem, that I see often in rspec tests.

Why?

Tests are software, so design concepts apply to them as well.
One of the key things is to keep them DRY.

In a words of Sandi Metz from Practical Object-Oriented Design in Ruby:

Removing duplication from testing lowers the cost of changing them in reaction to application changes(..)1

Use case

Take a look at this Ruby class.

1class Foo
2  def initialize(dependency:)
3    @dependency = dependency
4  end
5
6  def call(an_argument)
7    #...
8  end
9end

A class with one dependency that responds to one method which takes one argument.

For simplicity, lets assume that #call is a query method,
which can be tested by asserting a return value.

SPECS

What I’ve observed, developers tend to write specs structured more or less like:

 1describe Foo do
 2  let(:a_dependency) { .. }
 3  let(:other_dependency) { .. }
 4
 5  describe '#call' do
 6    context 'when initilaized with a_dependency' do
 7      context 'when something' do
 8        let(:argument) { .. }
 9        subject { Foo.new(dependency: a_dependency) }
10        it { expect(subject.call(argument)).to eq(123) }
11      end
12
13      context 'when not something' do
14        let(:argument) { .. }
15        subject { Foo.new(dependency: a_dependency) }
16        it { expect(subject.call(argument)).to eq(456) }
17      end
18    end
19
20    context 'when initialized with other_dependency' do
21      context 'when something' do
22        let(:argument) { .. }
23        subject { Foo.new(dependency: other_dependency) }
24        it { expect(subject.call(argument)).to eq(234) }
25      end
26
27      context 'when not something' do
28        let(:argument) { .. }
29        subject { Foo.new(dependency: other_dependency) }
30        it { expect(subject.call(argument)).to eq(567) }
31      end
32    end
33  end
34end

In above specs, different contexts are covered and subjects are described.
I believe that by looking at it you can tell what is going on there.

However, there are some repetitions which violate DRY principle:

  • Tested class name Foo is used few times (in lines: 1, 9, 15, 23, 29).
  • Knowledge about how to instantiate tested class is duplicated in each example (lines: 9, 15, 23, 29).
  • Knowledge about how to call tested method is duplicated as well (lines: 10, 16, 24, 30).

Therefore, if one of the followings change:

  • class name,
  • way of initializing (more/less injected dependencies),
  • method name,
  • arguments number.

..if one of above change in code, then 4-5 specs lines need to be adjusted keep the tests up to date.

It’s not that problematic, but still, improving it makes sense.

Refactor #step 1 - move lets into their contexts

Because declaring them on the top makes an impression that two dependencies need’s to be initialized in order to run the specs.
Which is not true - only one dependency is needed. There are two of them, because of two contexts.
So moving them into their contexts makes it easier to follow.

Contexts descriptions is making it clear how they vary between each other and the fact
that they have the same names makes it easier to understand that they represent the same being (lines: 4, 20)

 1describe Foo do
 2  describe '#call' do
 3    context 'when initilaized with a dependency' do
 4      let(:dependency) { .. }
 5
 6      context 'when something' do
 7        let(:argument) { .. }
 8        subject { Foo.new(dependency: dependency) }
 9        it { ... }
10      end
11
12      context 'when not something' do
13        let(:argument) { .. }
14        subject { Foo.new(dependency: dependency) }
15        it { ... }
16      end
17    end
18
19    context 'when initialized with other dependency' do
20      let(:dependency) { ... }
21
22      context 'when something' do
23        let(:argument) { .. }
24        subject { Foo.new(dependency: dependency) }
25        it { ... }
26      end
27
28      ...
29    end
30  end
31end

Refactor #step 2 - keep knowledge how to initialize tested class in one place

Because this knowledge is common for all examples.

In different contexts, different dependencies are used to instantiate tested class.
These dependencies can be declared later (in line 6), in their contexts, after subject declaration (line 3).

It works because of subject’s lazy evaluation - code in subject’s block is evaluated in line 7, not in line 3.

 1describe Foo do
 2  describe '#call' do
 3    subject { Foo.new(dependency: dependency) }
 4
 5    context 'when initilaized with a dependency' do
 6      let(:dependency) { .. }
 7      it { expect(subject.call(argument)).to eq(..) }
 8    end
 9
10    # ...
11  end
12end

If we wanted to test more methods, we would have to create more describe blocks.
In this case I would suggest extracting the knowledge about how to initialize tested class to a let block at the top.
After that - reuse it in subject declarations.

 1describe Foo do
 2  let(:foo) { Foo.new(dependency: dependency) }
 3
 4  describe '#a_method' do
 5    subject { foo.a_method(argument) }
 6    # ...
 7  end
 8
 9  describe '#other_method' do
10    subject { foo.other_method(argument) }
11    # ...
12  end
13end

Refactor #step 3 - keep knowledge how to call tested method in one place

For the same reasons as in previous step.

Instead of duplicating subject.call(argument) in each expectation, it could be moved into the
subject, because it’s the same in each case (line 5 in below listing).

Final version

  • Tested class name is used once (line: 1 - described_class).
  • Knowledge how to instantiate tested class is kept in one place (line: 2).
  • Knowledge how to call tested method is kept in one place, following it’s describe block (line: 5).
  • let blocks are declared within their contexts (lines: 8 and 22, 11 and 16, 25 and 30).

As a result of eliminating these duplications, specs became also more readable.
When looking at the nesting levels, we can see a kind of descending from general to detailed structure.

The general things are declared higher, closer to the top and the context-related details are nested deeper.

 1describe Foo do
 2  let(:foo) { described_class.new(depencency: dependency) }
 3
 4  describe '#call' do
 5    subject { foo.call(argument) }
 6
 7    context 'when initilaized with a dependency' do
 8      let(:dependency) { .. }
 9
10      context 'when something' do
11        let(:argument) { .. }
12        it { expect(subject).to eq(123) }
13      end
14
15      context 'when not something' do
16        let(:argument) { .. }
17        it { expect(subject).to eq(456) }
18      end
19    end
20
21    context 'when initialized with other dependency' do
22      let(:dependency) { .. }
23
24      context 'when something' do
25        let(:argument) { .. }
26        it { expect(subject).to eq(234) }
27      end
28
29      context 'when not something' do
30        let(:argument) { .. }
31        it { expect(subject).to eq(567) }
32      end
33    end
34  end
35end

Practice writing DRY specs. This habit pays of.

More reading


  1. Sandi Metz, Practical Object-Oriented Design in Ruby, Addison-Wesley, 2013, p.195 ↩︎