RSpec - Use shared_examples to avoid duplicate test examples
It's common to see a before_action
method that need executing in different controller actions. At the same time, when writing RSpec tests for these actions, it's easily to write down context blocks whose test examples are almost the same, and only a let
declaration is different.
The code might look like this:
Though these test examples seem readable, there are some shortcomings:
- When the rule of
#setup_discount
was changed, we need to revisewhen assigns @discount
test example groups ofFruitContrller#index
andFruitController#show
at the same time. Imagine when there are 10 controllers and all of them also need to execute#setup_discount
which means we have to revise the same thing 10 times. horrible... - It seems not DRY.
Fortunately, RSpec provides shared_examples
for us to solve the duplicate test examples problem.
How to use shared_examples
Take FruitContrller#index
and FruitController#show
RSpec tests for example.
- Add a new shared_examples file.
Before adding this new file, we need to check where to put this file so that it will be required automatically before running tests.
In my project, I follow the convention and put this file at:
spec/support/controllers/shared_examples/expected_discount_examples.rb
# spec/spec_helper.rb
Dir["./spec/support/**/*.rb"].sort.each { |f| require f }
2. Pick up the duplicate part.
For example,
# index request test examples
context 'when assigns @discount' do
let(:super_market) { create(:super_market) }
let(:user) { create(:user, special_pass_available: special_pass_available? }
#...
context 'when current_user owns special pass' do
let(:special_pass_available?) { true }
it { expect(assigns(:discount)).to eq(0.5) }
end
context 'when current_user does not own any special pass' do
let(:special_pass_available?) { false }
it { expect(assigns(:discount)).to eq(0) }
end
end
# show request test examples
context 'when assigns @discount' do
let(:super_market) { create(:super_market) }
let(:user) { create(:user, special_pass_available: special_pass_available? }
#...
context 'when current_user owns special pass' do
let(:special_pass_available?) { true }
it { expect(assigns(:discount)).to eq(0.5) }
end
context 'when current_user does not own any special pass' do
let(:special_pass_available?) { false }
it { expect(assigns(:discount)).to eq(0) }
end
end
3. Pick up the different parts.
For example,
# index request test examples
context 'when assigns @discount' do
#...
before { index_request }
#...
end
# show request test examples
context 'when assigns @discount' do
#...
before { show_request }
#...
end
4. Put the duplicate part in the shared_examples file.
RSpec.shared_examples 'an expected @fruit' do
let(:super_market) { create(:super_market) }
let(:user) { create(:user, special_pass_available: special_pass_available? }
#...
context 'when current_user owns special pass' do
let(:special_pass_available?) { true }
it { expect(assigns(:discount)).to eq(0.5) }
end
context 'when current_user does not own any special pass' do
let(:special_pass_available?) { false }
it { expect(assigns(:discount)).to eq(0) }
end
end
5. Setup request
variable for the different part
RSpec.shared_examples 'an expected @fruit' do
#...
before { request }
#...
end
6. Include the shared_examples into the test file.
RSpec official documentation provides four ways to include:
include_examples "name" # include the examples in the current context
it_behaves_like "name" # include the examples in a nested context
it_should_behave_like "name" # include the examples in a nested context
matching metadata # include the examples in the current context
I need to pass different request actions which are declared in subject
or let
into the shared group, so I use it_behaves_like
to include it.
After using shared_examples
, we solve two problems:
- When the rule of
#setup_discount
changes, all we need to do is to revise the shared_examples file and make a minor adjustment tolet
declaration. - It seems DRY now.
Although include_examples
didn't come in handy this time, there is something worth noting.
According to the official documentation, there is a warning message:
WARNING: When you include parameterized examples in the current context multiple times, you may override previous method definitions and last declaration wins.
To prevent this kind of subtle error a warning is emitted if you declare multiple methods with the same name in the same context. Should you get this warning the simplest solution is to replaceinclude_examples
withit_behaves_like
, in this way method overriding is avoided because of the nested context created byit_behaves_like
Read the detailed code sample: here