tirdadc

Web Development | Ruby on Rails | React.js

DRYing up your Rspecs with shared examples and shared contexts

This is an example of how to use shared contexts and shared examples to refactor your specs.

We have a Merchant model and a User model. We want to test 3 instance methods on Merchant that:

Let’s take the most naive approach and write 3 individual tests for these methods:

describe '#block' do
  let(:merchant) { build(:merchant) }
  let(:billing_manager) { create(:user, :manager, merchant: merchant) }

  before do
    merchant.billing_user = billing_manager
  end

  it 'sets the merchant state to blocked' do
    merchant.block
    expect(merchant.blocked?).to be true
  end

  it 'send an email notification' do
    expect {
      merchant.block
    }.to change(ActionMailer::Base.deliveries, :count).by(1)
  end
end

describe '#unblock' do
  let(:merchant) { build(:merchant) }
  let(:billing_manager) { create(:user, :manager, merchant: merchant) }

  before do
    # only a blocked user can be unblocked
    merchant.block
    merchant.billing_user = billing_manager
  end

  it 'sets the merchant state to active' do
    merchant.unblock
    expect(merchant.active?).to be true
  end

  it 'send an email notification' do
    expect {
      merchant.unblock
    }.to change(ActionMailer::Base.deliveries, :count).by(1)
  end
end

describe '#deactivate' do
  let(:merchant) { build(:merchant) }
  let(:billing_manager) { create(:user, :manager, merchant: merchant) }

  before do
    merchant.billing_user = billing_manager
  end

  it 'sets the merchant state to deactivated' do
    merchant.deactivate
    expect(merchant.deactivated?).to be true
  end

  it 'send an email notification' do
    expect {
      merchant.deactivate
    }.to change(ActionMailer::Base.deliveries, :count).by(1)
  end
end

You’re testing the same things on all 3 methods, so you can leverage shared examples to rewrite that in a way where you don’t repeat that. You also want to pass both the action to trigger and the new state to test as parameters to your shared example:

shared_examples_for 'a merchant state change' do |action, new_state|
  it "sets the merchant state to #{new_state}" do
    merchant.send(action)
    expect(merchant.send("#{new_state}?")).to be true
  end

  it 'sends an email notification' do
    expect {
      merchant.send(action)
    }.to change(ActionMailer::Base.deliveries, :count).by(1)
  end
end

describe '#block' do
  let(:merchant) { build(:merchant) }
  let(:billing_manager) { create(:user, :manager, merchant: merchant) }

  before do
    merchant.billing_user = billing_manager
  end

  it_behaves_like 'a merchant state change', 'block', 'blocked'
end

describe '#unblock' do
  let(:merchant) { build(:merchant) }
  let(:billing_manager) { create(:user, :manager, merchant: merchant) }

  before do
    merchant.billing_user = billing_manager
    merchant.block
  end

  it_behaves_like 'a merchant state change', 'unblock', 'active'
end

describe '#deactivate' do
  let(:merchant) { build(:merchant) }
  let(:billing_manager) { create(:user, :manager, merchant: merchant) }

  before do
    merchant.billing_user = billing_manager
  end

  it_behaves_like 'a merchant state change', 'deactivate', 'deactivated'
end

Now that you’ve done that, you want to avoid repeating the setup portion for these tests. You can do that with shared contexts:

shared_examples_for 'a merchant state change' do |action, new_state|
  it "sets the merchant state to #{new_state}" do
    merchant.send(action)
    expect(merchant.send("#{new_state}?")).to be true
  end

  it 'sends an email notification' do
    expect {
      merchant.send(action)
    }.to change(ActionMailer::Base.deliveries, :count).by(1)
  end
end

shared_context 'has a merchant and a billing manager' do
  let(:merchant) { build(:merchant) }
  let(:billing_manager) { create(:user, :manager, merchant: merchant) }

  before do
    merchant.billing_user = billing_manager
  end
end

describe '#block' do
  include_context 'has a merchant and a billing manager'
  it_behaves_like 'a merchant state change', 'block', 'blocked'
end

describe '#unblock' do
  include_context 'has a merchant and a billing manager'
  before do
    merchant.block
  end

  it_behaves_like 'a merchant state change', 'unblock', 'active'
end

describe '#deactivate' do
  include_context 'has a merchant and a billing manager'
  it_behaves_like 'a merchant state change', 'deactivate', 'deactivated'
end

You’re now down from 61 lines to 40, and it just looks less copy-paste happy. You can also move out your shared contexts to separate files and reuse them in different specs. The only immediate downside is that the line numbering is a bit less informative with shared examples compared to distinct specs.

Like anything DRY-related, use this responsibly and don’t create a nightmare bizarro world of obfuscated tests.