Shared Examples and Contexts in RSpec

When I first learned testing in Rails, I learned RSpec, but then in my last job, I wrote most of my tests in minitest. I enjoyed the challenge of learning a new test framework and found the tests super fast!

But recently, I’ve been working on a project in RSpec again and I have a renewed appreciation for it! In this post, I’m going to talk about how to define and use shared examples and shared contexts in RSpec.

I’m going to start with shared examples here because shared contexts are very similar in how you set them up.

Shared Examples

Shared examples are a set of examples that you want to use in multiple files. You’ll want to use shared examples when you’re writing a Module or a Concern in Rails.

In this example below, we have two models, Door and Phone, and they both can be locked. When we lock one of these things, we set an attribute on the model called locked_at, which is a timestamp of when the item was locked.

# app/models/door.rb
class Door < ApplicationRecord
  include Lockable
end

# app/models/phone.rb
class Phone < ApplicationRecord
  include Lockable
end

# app/models/concerns/lockable.rb
module Lockable
  extend ActiveSupport::Concern

  included do
    scope :locked, -> { where.not(locked_at: nil)  }
    scope :locked_between, (start_time, end_time)-> { where(locked_at: start_time..end_time)  }
  end

  def lock!
    update locked_at: Time.zone.now
  end
end

You can see here that both models share some functionality through the Lockable module. This module lets us lock items and find all items that are locked.

Our tests can then be set up like this:

# spec/models/door_spec.rb
RSpec.describe Door, type: :model do
  it_behaves_like 'Lockable'
end

# spec/models/phone_spec.rb
RSpec.describe Phone, type: :model do
  it_behaves_like 'Lockable'
end

# spec/support/shared_examples/lockable_spec.rb
RSpec.shared_examples 'Lockable' do
  subject { described_class.create }

  describe '#lock' do
    it 'sets locked_at to current time' do
      freeze_time do
        expect { subject.lock! }.to change { subject.locked_at }.from(nil).to(Time.now.utc)
      end
    end
  end
end

Now when we run our model tests, we get two passing tests. One for each model!

$ bundle exec rspec spec/models/
..

Finished in 0.02916 seconds (files took 1.28 seconds to load)
2 examples, 0 failures

Parameters

What happens if you need to pass some parameters that might be different in one context but not the other? Shared examples accept parameters! In the example below, I am creating an instance of the subject class and passing it into the shared example.

# spec/models/door_spec.rb
RSpec.describe Door, type: :model do
  it_behaves_like 'Lockable'
end

# spec/models/phone_spec.rb
RSpec.describe Phone, type: :model do
  it_behaves_like 'Lockable'
end

# spec/support/shared_examples/lockable_spec.rb
RSpec.shared_examples 'Lockable'
  subject { described_class.create  }

  describe '#lock' do
    it 'sets locked_at to current time' do
      freeze_time do
        expect { subject.lock! }.to change { subject.locked_at }.from(nil).to(Time.now.utc)
      end
    end
  end
end

Gotchas!

RSpec doesn’t auto-load any files for you! You’ll see that I’ve put my shared examples in spec/support/shared_examples/. You need to tell RSpec to require this file.

# spec/rails_helper.rb
Dir["./spec/support/**/*.rb"].sort.each { |f| require f  }

Shared Contexts

What about times when you have to do a whole bunch of contextual setup in your tests? It gets super annoying when you have to do this setup twice. Shared contexts define code that will be evaluated before the tests run.

Let’s add a method called locked? to our module so that we can see if a door or phone is locked.

# app/models/concerns/lockable.rb
module Lockable
  extend ActiveSupport::Concern

  # Other code excluded for brevity.

  def locked?
    locked_at.present?
  end
end

Now we want to add a shared context that creates a new subject for us. I’m also going to define a locked_at let block here.

# spec/support/shared_contexts/locked_at.rb
RSpec.shared_context 'locked_at' do
  let(:locked_at) { nil }
  subject { described_class.new(locked_at: locked_at) }
end

And finally, lets add some tests to our shared examples for lockable:

# spec/support/shared_examples/lockable_spec.rb
RSpec.shared_examples 'Lockable' do
  subject { described_class.create }

  # Other code excluded for brevity.

  describe '#locked?' do
    context 'it is not locked' do
      include_context 'locked_at'

      it 'returns true' do
        expect(subject.locked?).to be false
      end
    end

    context 'it is locked' do
      include_context 'locked_at' do
        let(:locked_at) { Time.now.utc }
      end

      it 'returns false' do
        expect(subject.locked?).to be true
      end
    end
  end
end

Here, the shared context is setting up a new subject for us. When we want to change one of the parameters of the context, we can use a block. In the test to ensure a subject is locked, you can see I have overridden the locked_at variable.

Conclusion

I would choose carefully when implementing these though! Like Sandi Metz says, “duplication is far cheaper than the wrong abstraction.”

If you find your shared contexts always have a block that overrides some defaults, or your shared examples require lots of parameters to set up, you might have the wrong abstraction.

Shared contexts and examples are great for cleaning up your code when you need them though!

You can find the code from this post over on GitHub: https://github.com/cassidycodes/rspec-examples