RSpec - Use shared_examples to avoid duplicate test examples

RSpec - Use shared_examples to avoid duplicate test examples
Photo by Christin Hume / Unsplash

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:

  1. When the rule of #setup_discount was changed, we need to revise when assigns @discount test example groups of FruitContrller#index and FruitController#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...
  2. 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.

  1. 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 to let 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 replace include_examples with it_behaves_like, in this way method overriding is avoided because of the nested context created by it_behaves_like

Read the detailed code sample: here


Reference Info: