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
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
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_context
s 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
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
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)