Simon.Fish

Engineering and educating for a free and open web.

Ruby logo Ruby on Ruby on Rails logo Rails developer with four years of industry experience.
Experienced with Turbo logo Hotwire, ViewComponent logo ViewComponent, and more.

Used under the Unsplash License. Originally by Ayrus Hill.

Subject to Change: Effective Use of RSpec

RSpec is a Ruby library I'm very thankful for. It results in very semantic tests when used correctly, and tools like the different output types make it easy to understand and integrate with other platforms. But, like Rails itself, it's easy to misuse and write tests in a way that isn't scalable, or is hard to understand. So I'm here to set the record straight with a few things I've learned in the last couple of years.

This article should be a good read if you've gotten to grips with RSpec and can use describe blocks to write examples effectively, but I'll also be discussing these RSpec features at a more basic level so that it's easy to understand for newcomers.

subject vs. let

RSpec's let helpers are invaluable. They memoize values for you, and also allow you to change select variables between contexts, like this:

RSpec.describe Guitar do
  let(:guitar) { Guitar.new(tuning) }

  describe '#pluck' do
    context 'when tuning is EADGBE' do
      let(:tuning) { 'EADGBE' }

      it 'plays E on the bottom string' do
        expect(guitar.pluck(0)).to eq('E2')
      end
    end

    context 'when tuning is drop D' do
      let(:tuning) { 'DADGBE' }

      it 'plays D on the bottom string' do
        expect(guitar.pluck(0)).to eq('D2')
      end
    end
  end
end

To go into what's happening here, each of the test cases will call guitar to get an instance of Guitar. guitar calls the tuning let block we've defined to get the guitar's tuning, which we'll use to initialize it. So even though we're calling guitar.pluck(0) in both cases, we're referring to two different instances of guitar with two different tunings, because tuning is different in each context.

That's good to start with, but as we'll soon see, a lot of optimizations can be made to keep this test DRY.

The subject of discussion

You might have seen subject used in place of let on occasion. subject functions just the same as let, but it's got a few extra tricks up its sleeve that you may not know about. What some folks will do is declare a subject as something they're testing against, such as:

subject(:guitar) { Guitar.new(tuning) }

But there's a problem here. In the context of our last example, guitar isn't the subject - guitar.pluck(0) is! We're testing against the result of that method, which makes it the subject of the tests, even if the common thread is guitar. But it can be argued that even that's not true - guitar differs between contexts due to how we're initializing it, so really, it's not the subject after all. Let's try that again, shall we?

subject's partner in crime

describe '#pluck' do
  subject { guitar.pluck(0) }

  context 'when the tuning is EADGBE' do
    let(:tuning) { 'EADGBE' }

    it 'plays E on the bottom string' do
      expect(subject).to eq('E2')
    end

That's an improvement. It means that if the interface for pluck ever changes, we only ever need to change calls in one place. Or two, if the name decides to change. But we're not done yet - here's where the magic happens.

describe '#pluck' do
  subject { guitar.pluck(0) }

  context 'when the tuning is EADGBE' do
    let(:tuning) { 'EADGBE' }

    it { is_expected.to eq('E2') }

Here's subject at full power. Subjects can be referred to implicitly with it. Combine that with matchers like have_attributes and satisfy, and you can have very short and well-contained tests.

The icing on the cake - described_class

described_class is a helper that's available within describe blocks. It does just what it says on the tin and returns the described class, which in this case is Guitar. Using this gives similar benefits to subject and ensures that your tests won't need much tweaking in the event of a class name change.

This really helped me when extracting nvar out of the Raise.dev codebase and into its own gem - within the codebase, it was referred to as EnvironmentVariable, but that needed putting under the Nvar namespace when I extracted it out. That's just one example of a case in which you might need to rename a class.

Let's bring it all together!

RSpec.describe Guitar do
  # We use `subject` out here too so that we can test against this directly.
  # Within the context of #describe blocks, though, the subject is the
  # method call.
  subject(:guitar) { described_class.new(tuning) }

  it { is_expected.to have_attributes(make: 'Yamaha') }
  it { is_expected.to respond_to(:acts_like_stringed_instrument?) }

  describe '#pluck' do
    subject { guitar.pluck(0) }

    context 'when tuning is EADGBE' do
      let(:tuning) { 'EADGBE' }

      it { is_expected.to eq('E2') }
    end

    context 'when tuning is drop D' do
      let(:tuning) { 'DADGBE' }

      it { is_expected.to eq('D2') }
    end
  end
end

And for those unaware, I should also mention that before, after, and around hooks can be very useful in setting up tests. They combine well with this setup. Check out the docs for more on those.

Summary

So, TL;DR:

  • subject should refer to the object under test, whether that's an instance of the described class or the result of a method call against it.
  • Using subject correctly allows you to use the implicit syntax: it { is_expected.to ... }
  • Using matchers like have_attributes or satisfy is a good way to go one level deeper if necessary: it { is_expected.to have_attributes(songs: contain_exactly('Bohemian Rhapsody')) }, for example, checks that the object under test responds to songs and returns an enumerable containing only the string 'Bohemian Rhapsody'
  • before, after, and around hooks can be useful in setting up tests.

All of these things work together to make your specs easier to read and write, and make them easier to update in the event of a change. They really feel like an implicit structure that RSpec encourages you towards, but it does surprise me how often I see these features go unused or misused.