Structuring RSpec files
RSpec is honestly the best testing framework I have ever worked with, and I dread the day I will to switch to a language that doesn't have a similar tool under its belt. However, RSpec and its documentation are very quiet when it comes to good practices. The docs are full to the brim with examples and details about the features of the framework, but not so much when it comes to how to use these features to build a good test suite.
I have worked with RSpec for multiple years now, and I have recently noticed that, while there are lots of documentation and blogs and Stack Overflow posts and whatnot out there, I rarely see people talk about how they organize their spec files. And in my opinion, the difference between a tidy spec and a messy one is gigantic: legibility, maintainability, collaboration... You name it. Every single aspect of an automated test suite can be improved if you take up some good habits regarding the shape of the tests.
So here you go. I this article, I will give a basic rundown of how I write a spec file, what is the logic behind it. There are also a bunch of miscellaneous tips and tricks to improve your day-to-day experience.
Requirements
I will not cover the basics of RSpec usage here. I will assume that you know most of what there is to know about "describe", "it", "let", and so on.
I tried to add as many links to the documentation as possible, in order to make sure everything is clear.
Folder structure
I use the folder structure that is recommended by RSpec themselves, which is a spec
folder, that lives alongside the lib
folder. In this folder lives the spec_helper.rb
file. The spec files follow the same structure as the code files in the lib
folder, each one with the _spec.rb
suffix, which helps RSpec differentiate the spec files from fixture files, READMEs and other non-test files.
my-gem/
├─ .rspec
├─ lib/
│ ├─ my-gem.rb
│ └─ my_gem/
│ ├─ user.rb
│ ├─ api.rb
│ └─ api/
│ └─ client.rb
└─ spec/
├─ spec_helper.rb
└─ my_gem/
├─ user_spec.rb
└─ api/
└─ client_spec.rb
File structure
Describe class / subject
Let's take a look at some_class_spec.rb
. Imagine that I have not written the tests yet. If you're a TDD aficionado, you could say I have not even written the class yet.
The very first lines I write are almost exactly the same. Here is how they go.
# spec/my_gem/user_spec.rb
# frozen_string_literal: true
describe User do
subject(:user) { described_class.new }
end
Using subject
instead of let
has two merits here:
- It makes it crystal clear what the object under test is, no matter how convoluted the spec file gets;
- Specifically, it is sure to stay at the top of the file. It must be the first thing anyone sees when they open the spec file.
- It lets us use some of that oh-so satisfying RSpec syntactic sugar.
Also, naming your subject is always a good idea. When testing specific methods, it lets you show an example of what an actual usage might look like. In particular, try to name your subjects as the class itself when possible, just for consistency.
If the constructor has parameters, turn them into let
and give them "nominal" values. You'll see later why in detail, but the gist of it that we want the specs to go from the most simple one first, to the most specific ones last, and sensible defaults let you set up nominal cases faster.
# frozen_string_literal: true
describe User do
subject(:user) { described_class.new(first_name, last_name) }
let(:first_name) { 'Richard' }
let(:last_name) { 'Degenne' }
end
- Keep your
let
in the same order as the order of the arguments.
Describing a method
Each method of your class must have its own describe
block. Also, make sure to have to different method describe
blocks in the same order as they are defined in the code. You know, for consistency.
# frozen_string_literal: true
describe User do
subject(:user) { described_class.new(first_name, last_name) }
let(:first_name) { 'Richard' }
let(:last_name) { 'Degenne' }
describe '#full_name' do
subject(:full_name) { user.full_name }
end
end
Again, I use subject
to make clear what it is that I am currently testing, and to use RSpec's syntax to the max. Already, we can write a first test, thanks to the sensible defaults we defined earlier.
# frozen_string_literal: true
describe User do
subject(:user) { described_class.new(first_name, last_name) }
let(:first_name) { 'Richard' }
let(:last_name) { 'Degenne' }
describe '#full_name' do
subject(:full_name) { user.full_name }
it { is_expected.to eq 'Richard Degenne' }
end
end
Pretty neat, if I do say so myself.
Adding contexts
Now that we have tested a nominal case, we can start digging into all the different possibilities. For instance, what happens if the User has no first name?
# frozen_string_literal: true
describe User do
subject(:user) { described_class.new(first_name, last_name) }
let(:first_name) { 'Richard' }
let(:last_name) { 'Degenne' }
describe '#full_name' do
subject(:full_name) { user.full_name }
it { is_expected.to eq 'Richard Degenne' }
context 'without a first name' do
let(:first_name) { nil }
it { is_expected.to eq 'Mr/Mrs. Degenne' }
end
end
end
Generally, the first lines of a context should be a literal translation of the context name in RSpec terms. It makes it very clear what each let
is doing and why it is here. Then, follow with any number of it
you might need. Let's keep going.
# frozen_string_literal: true
describe User do
subject(:user) { described_class.new(first_name, last_name) }
let(:first_name) { 'Richard' }
let(:last_name) { 'Degenne' }
describe '#full_name' do
subject(:full_name) { user.full_name }
it { is_expected.to eq 'Richard Degenne' }
context 'without a first name' do
let(:first_name) { nil }
it { is_expected.to eq 'Mr/Mrs. Degenne' }
end
context 'without a last name' do
let(:last_name) { nil }
it { is_expected.to eq 'Richard' }
end
context 'without a first name nor last name' do
let(:first_name) { nil }
let(:last_name) { nil }
it { is_expected.to be_nil }
end
end
end
Nesting contexts
This last context
is interesting. You might be tempted to nest it inside of "without a last name" or "without a first name", but I believe this is a mistake.
Nesting contexts might look like it's DRYing up you test, but it can have a couple adversary side effects:
- It couples the setup for one test with the other, which means you might end up with extraneous, unwanted setup for the inner context;
- It splits the setup in two parts If the outer context is a bit long, especially if it has a couple of tests or other nested contexts, that means that reading the setup for the inner context requires you to look at two different places, which makes the test less legible.
In my opinion, nesting contexts should be done very sparingly, only in cases where it makes perfect sense, and provided that the inner contexts are not so long or so complex as to jeopardize the legibility of the overall structure.
Keep going like this for all the methods in the User class, method after method, in the same order as the code file and you'll start to see some sort of repetitive logic here.
describe
Classsubject
- Initialization
let
s describe
methodsubject
- Method call
let
s - Nominal
it
s context
- Setup
let
s - Specific
it
s
- Setup
Repeat until you've reached the end of the class. This methodical, almost mechanical approach to writing tests make it very easy to counter the blank page syndrome, and will ensure that all of your test files will look the same, read the same. Very satisfying, if you ask me.
Mocking
When writing unit tests, it's important to minimize the amount of interaction between the code under test and external factors, such as another class, or, God forbid, another service such as an API.
RSpec offers a bunch of stuff relative to mocking, stubbing and spying. And, just like in the previous section, I believe that the key to clean mocking is two-fold: organization and consistency.
Let's look at another method from our User class for example.
# frozen_string_literal: true
describe User do
subject(:user) { described_class.new(first_name, last_name) }
let(:first_name) { 'Richard' }
let(:last_name) { 'Degenne' }
# NOTE: I usually describe class methods above instance methods,
# since I usually define them that way in the code files.
describe '.find' do
subject(:find) { described_class.find(id) }
let(:id) { 42 }
let(:client) { instance_double(API::Client, find_user: user) }
before do
allow(API::Client).to receive(:new).and_return(client)
end
it { is_expected.to eq user }
end
# ...
end
So, a couple notes about this snippet.
- Leave a blank line between the
let
s that are used for method calls and thelet
s that are used for mocks. - Always use
instance_double
when possible and use the shorthand response syntax. Use the longerallow(client).to receive
syntax when necessary (when mocking errors, for instance)
There are two ways to write tests using mocks. Either you check that the mock has been called, or you check that the return value of your code is consistent with the mock. I would say that this question is merely a matter of Command-Query Separation (CQS).
If the call to the external service is a command (i.e. a call that performs an action), then check that the mock has been called. If the call is a query (i.e. a call that returns a value), then check the behavior of your code.
For instance, in the snippet above, the call to API::Client#find_user
is clearly a query, so I set a sensible return value (a User) and use that in my test. If the implementation does not call the mock, then it cannot return the correct value, and the test will fail.
On the other hand, let's look at another method that uses a command.
# frozen_string_literal: true
describe User do
subject(:user) { described_class.new(first_name, last_name) }
let(:first_name) { 'Richard' }
let(:last_name) { 'Degenne' }
describe '#save' do
subject(:save) { user.save }
let(:client) { instance_double(API::Client, save: true) }
before do
allow(API::Client).to receive(:new).and_return(client)
end
it 'saves the user to the API' do
save
expect(client).to have_received(:save)
end
end
# ...
end
Here, what is important is that the command was called. Note that we probably should also test that the mock was called with the correct arguments.
Here is an example of a context
used to simulate a server-side error.
# frozen_string_literal: true
describe User do
subject(:user) { described_class.new(first_name, last_name) }
let(:first_name) { 'Richard' }
let(:last_name) { 'Degenne' }
describe '#save' do
subject(:save) { user.save }
let(:client) { instance_double(API::Client, save_user: true) }
before do
allow(API::Client).to receive(:new).and_return(client)
end
it 'saves the user to the API' do
save
expect(client).to have_received(:save)
end
context 'when the API fails' do
before do
allow(client).to receive(:save_user).and_raise(API::Error, 'Invalid user')
end
it 'fails with the same error' do
expect { save }.to raise_error(API::Error, 'InvalidUser')
end
end
end
# ...
end
Just like in the previous section, the very first lines of the block are a mere transcription of the context's title into RSpec lingo, which make the whole thing very intuitive to read.
DRYing up
When it comes to factorizing stuff, such as mock setups and things like that, I try not to be too proactive. Don't get me wrong, factorization is good and necessary in a lot of places, but it also comes with consequences. If you are going to DRY up something, make sure that you are aware of these implications and that you are ready to accept them.
It can make the test harder to read. One of my key philosophies when it comes to tests is that closer is better. If two pieces of code are logically related to each other, then I try to keep them as close together as possible. There is nothing more frustrating than a snippet of code failing because of some random junk in a random file at the other end of the project. Factorizing tends to split related information across different places, so make sure that this process is clear and intuitive. Usually, the structure I've shown in the previous sections does the job, but keep an eye out for more complicated situations.
It can make the test harder to maintain. When DRYing up a piece of code that is shared between two or more branches, you make the hypothesis that these branches will always use the same, factorized code. If, later down the line, one of the branches diverges and needs a different, distinct code, then you will have two options: either split the dry code into two parts, or, add stuff to the dry bit, even though the other branches don't need it. This second option will lead to bloat, code paths that are unused in the other branches, but is also the easy way out, since it doesn't need refactoring a bunch of stuff.
The same can be said of tests, especially if you have a tendency to nest lots of contexts inside one another. Are all the setup and mocks of the outer contexts useful to the inner contexts? If two methods use the same mock, do you really need to set it up for all the methods of the class?
Conclusion
Automated test design is a topic that I like a lot, and I will probably write more about it. In particular, Ruby (and Rails) have lots of very neat tools and techniques that are worth checking out, but that will be for another day.
In the meantime, feel free to refer back to this article whenever you're looking to write your next spec file, or if you stumble onto a jumbo mess of a spec file left over by a coworker, and you want to clean it up. Whatever it is about RSpec, always remember.
Consistency is the key.