DEV Community

Cover image for Eliminate hardcore nesting in your RSpec tests by using Ruby procs
Jan Bajena
Jan Bajena

Posted on • Edited on

Eliminate hardcore nesting in your RSpec tests by using Ruby procs

Recently I was testing a service class in Ruby and I needed to make sure that specific combinations of conditions produce desired effects.

First, I started nesting the conditions in contexts like I'd do normally, so my tests would look like this:

context 'when A1' do
  let(:a1) {}

  it 'R1' do
    # ...
  end

  context 'and B1' do
    let(:b1) {}

    it 'R1' do
      # ...
    end
  end

  context 'and B2' do
    let(:b2) {}

    it 'R1' do
      # ...
    end
  end
end

context 'when A2' do
  let(:a2) {}

  it 'R2' do
    # ...
  end

  context 'and B1' do
    let(:b1) {}

    it 'R3' do
      # ...
    end
  end

  context 'and B2' do
    let(:b2) {}

    it 'R2' do
      # ...
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

In my case there was one more level of nesting, so the code was really unreadable and hard to understand. Here I removed one level and cleaned up the context contents to save your eyes dear reader :)

As you can see there was also a lot of repetition, so the first thing I did was extracting the repeating contexts into shared_context blocks and repeating test cases into shared_examples, so the code became similar to this:

shared_context 'A1' do
  let(:a1) {}
end

shared_context 'A2' do
  let(:a2) {}
end

shared_context 'B1' do
  let(:b1) {}
end

shared_context 'B2' do
  let(:b2) {}
end

shared_examples 'R1' do
  it 'R1' do
    # ...
  end
end

shared_examples 'R2' do
  it 'R2' do
    # ...
  end
end

shared_examples 'R3' do
  it 'R3' do
    # ...
  end
end

context 'A1' do
  include_context 'A1'

  include_examples 'R1'

  context 'B1' do
    include_context 'B1'

    include_examples 'R1'
  end

  context 'B2' do
    include_context 'B2'

    include_examples 'R1'
  end
end

context 'A2' do
  include_context 'A2'

  include_examples 'R2'

  context 'B1' do
    include_context 'B1'

    include_examples 'R3'
  end

  context 'B2' do
    include_context 'B2'

    include_examples 'R2'
  end
end
Enter fullscreen mode Exit fullscreen mode

Ok, at least now, whenever the details of a test or context case change I wouldn't have to update all the places.

However, the tests would still be difficult to read due to nesting, so I thought that it might be cool to define each scenario in a single line instead of having to browse through endless nested blocks.

Imagine something like in the snippet below. I translated the nested contexts and examples from previous code block, so that in each line the last element of the array defines the expected shared_examples group and all the previous elements are names of shared_contexts that need to be deeply nested one after another:

[
  ['A1', 'R1'],
  ['A1', 'B1', 'R1'],
  ['A1', 'B2', 'R1'],
  ['A2', 'R2'],
  ['A2', 'B1', 'R3'],
  ['A2', 'B2', 'R2'],
].each do |scenario|
  # ... do some Ruby + rspec magic here
end
Enter fullscreen mode Exit fullscreen mode

Reads much better, right? Let's see how the full solution looks like:


[
  ['A1', 'R1'],
  ['A1', 'B1', 'R1'],
  ['A1', 'B2', 'R1'],
  ['A2', 'R2'],
  ['A2', 'B1', 'R3'],
  ['A2', 'B2', 'R2'],
].each do |scenario|
  # All but last element of the array
  contexts = scenario[0..-2]
  test_example = scenario.last

  # Let's prepare a deeply nested set of Procs with all the required nesting to achieve the same result. 
  # A tricky thing to understand here is that we need to start from the most nested block and go up,
  # until the most outer context.
  contexts.reverse.inject(proc { include_examples(test_example) }) do |inner, ctx|
    proc do
      context ctx do
        include_context(ctx, &inner)
      end
    end
  end.call
end
Enter fullscreen mode Exit fullscreen mode

I understand that this solution might be very specific to the problem I had, but perhaps it might inspire you to refactor your tests and improve their readability.

If you know a simpler or better method (a Ruby gem? Built-in RSpec functionality?) of dealing with such problems please let me know in a comment or private message.

Top comments (0)