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 tuning
s, 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
orsatisfy
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 tosongs
and returns an enumerable containing only the string'Bohemian Rhapsody'
-
before
,after
, andaround
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.