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.

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

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:

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
describe Foo do
  let(:a_dependency) { .. }
  let(:other_dependency) { .. }
  
  describe '#call' do
    context 'when initilaized with a_dependency' do
      context 'when something' do
        let(:argument) { .. }
        subject { Foo.new(dependency: a_dependency) }
        it { expect(subject.call(argument)).to eq(123) }
      end

      context 'when not something' do
        let(:argument) { .. }
        subject { Foo.new(dependency: a_dependency) }
        it { expect(subject.call(argument)).to eq(456) }
      end
    end

    context 'when initialized with other_dependency' do
      context 'when something' do
        let(:argument) { .. }
        subject { Foo.new(dependency: other_dependency) }
        it { expect(subject.call(argument)).to eq(234) }
      end

      context 'when not something' do
        let(:argument) { .. }
        subject { Foo.new(dependency: other_dependency) }
        it { expect(subject.call(argument)).to eq(567) }
      end
    end
  end
end

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)

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
describe Foo do
  describe '#call' do
    context 'when initilaized with a dependency' do
      let(:dependency) { .. }
      
      context 'when something' do
        let(:argument) { .. }
        subject { Foo.new(dependency: dependency) }
        it { ... }
      end

      context 'when not something' do
        let(:argument) { .. }
        subject { Foo.new(dependency: dependency) }
        it { ... }
      end
    end

    context 'when initialized with other dependency' do
      let(:dependency) { ... }
      
      context 'when something' do
        let(:argument) { .. }
        subject { Foo.new(dependency: dependency) }
        it { ... }
      end
      
      ...
    end
  end
end

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.

1
2
3
4
5
6
7
8
9
10
11
12
describe Foo do
  describe '#call' do
    subject { Foo.new(dependency: dependency) }

    context 'when initilaized with a dependency' do
      let(:dependency) { .. } 
      it { expect(subject.call(argument)).to eq(..) }
    end

    # ...
  end
end

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
describe Foo do
  let(:foo) { Foo.new(dependency: dependency) }

  describe '#a_method' do
    subject { foo.a_method(argument) }
    # ...
  end
  
  describe '#other_method' do
    subject { foo.other_method(argument) }
    # ...
  end
end

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.

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
describe Foo do
  let(:foo) { described_class.new(depencency: dependency) }
  
  describe '#call' do
    subject { foo.call(argument) }

    context 'when initilaized with a dependency' do
      let(:dependency) { .. } 

      context 'when something' do
        let(:argument) { .. }
        it { expect(subject).to eq(123) }
      end

      context 'when not something' do
        let(:argument) { .. }
        it { expect(subject).to eq(456) }
      end
    end

    context 'when initialized with other dependency' do
      let(:dependency) { .. }

      context 'when something' do
        let(:argument) { .. }
        it { expect(subject).to eq(234) }
      end

      context 'when not something' do
        let(:argument) { .. }
        it { expect(subject).to eq(567) }
      end
    end
  end
end

Practice writing DRY specs. This habit pays of.

More reading