Guidelines for writing better specs
As we’ve seen in one of the previous posts in this series, RSpec has a lot of cool features that can easily be misused and can make your test suite really painful, hard to work with, affect the team productivity and morale, enrage the management and ultimately lose confidence in your own code.
We’ve also analyzed the benefits of a more direct approach to testing using Minitest and the Four-phase testing pattern in the previous post
In this post, we will briefly tell you what we did in those projects where changing the testing stack was not viable. We will define a reduced and safe subset of the RSpec features and a set of guidelines on how to use them to avoid the problems we outlined.
TL;DR
This is the set of guidelines we came up with after a thorough analysis of the test suite, the problems it was facing and their root causes:
Use
context
to define the test scenario in natural languageUse
context
to group related tests togetherKeep factories to their minimal expression (pass validations)
Don’t put test setups in factories
Don’t put test setups in contexts that have nested contexts
Don’t put
it
s incontext
s that have nestedcontext
sUse
let!
to define objects in the setup that theit
will use to exercise and verifyUse
before
to define objects and relations that must be there but are not directly used in the followingit
Define test setup in the context that has the
it
sDo not use
let
Do not use shared examples
Do not use
subject
Repetition in test setups is ok
If repetition is unbearable, extract the setup into a tailored factory method inside an instance method in the same spec file.
Read ahead if you would like to know the reasoning behind each of these guidelines
Main pain points
Let’s first revisit the main issues we faced in those projects. The root cause of those issues was the deep inheritance tree of test setups. This inheritance tree was enabled by 2 things:
RSpec’s ability to allow infinitely nested contexts
Naively optimizing code-writing: who wants to write again the same test setup that I wrote 15 lines above?
The two test anti-patterns that this produced were:
Global Shared Fixtures
Mystery Guests
Global shared fixtures
See: Standard Fixture
TL;DR: This means that the setup is shared among several test cases.
This brings three main problems:
As it is global all the tests that depend on the fixture are coupled together.
As there are many test cases depending on it, it will probably create more things than needed for each specific test
There’s a lack of information categorization. As it holds details of many test setups, is hard to tell which part of it is important on a given test.
A global fixture usually lives in:
Factories
let
s andbefore
s near the top of specs with deeply nested contexts
Mystery Guest
The test reader is not able to see the cause and effect between fixture and verification logic because part of it is done outside the Test Method.
see: Mystery Guest
This means the test is running on top of out-of-band information that the dev needs to search, find, and reconstruct to fully understand what’s going on in the test.
Usually found in:
Factories
let
s andbefore
s near the top of specs with deeply nested contextsfixture files stored on disk
The Safe Toolset
As you can already imagine we used the conceptual structure of the Four-Phase Test Pattern:
Setup
Exercise
Verification
Teardown
So, the first step was to define a mapping between Rspec DSL and the four phases. Here’s what we came up with:
before
,let
andlet!
are the Setup phaseit
will hold both the Exercise and Verification phasesafter
is Teardown
Surprisingly, there are no mappings for context
or describe
. This was the first revelation we had. The source of a lot of the problems we had is just not there anymore :). And so, the first question arose: if they are not an essential part of the test, why are they there? what is their mission?
The conclusions we arrived at are:
They are mandatory: you need at least one
describe
as the entry point of the rspec tests.They are documentation, the description of the test case. Similar to the method name in
Minitest::Unit
, or the#test
method inActiveSupport::TestCase
They are used in the test runner output, which is quite nice with nested contexts
So, taking all this into account, how can we use rspec and avoid the mentioned problems?
Guidelines
Here we will outline a few DOs and DON’Ts on how to use each of the methods in the safe subset and other tools and methods.
describe / context
Use describe
and context
as descriptions. The idea is to use these descriptions as the specification of a test’s setup. A code review can easily detect if there are discrepancies between test specification (what the dev wants to test) and implementation (what is actually being tested).
DOs:
use them to explicitly communicate to the reader what’s the test case and how the test setup should be built
use them to group related tests together (nesting contexts is ok if you respect the DON’Ts)
be verbose
DON’Ts:
Do not use
let
s orbefore
s if the context has nested contextsDo not use
it
s if the context has nested contexts
let and let!
This one is one of the most interesting in my opinion. Here goes the controversial statement:
let
andlet!
are just a DSL that hides away variable assignments. You don’t really need them to express a test case setup.
Do you agree?
let
is even more problematic, as it only makes sense if you have a massive shared fixture at the top of the test file.
One can understand the reason behind let
: rspec is trying to solve problem number 2 (creating more things than needed for each specific test, which makes the test brittle and slow). But, in my opinion, this would have been the first symptom showing us that something is not ok: The problem is not that variable assignment is not lazy/performant, the problem is the huge shared fixture at the top. Without it, you don’t need lazy variable assignments. A good old plain assignment that takes effect where it is defined is more than enough, straightforward, no second guessing when it is going to actually be defined.
Anyway, we didn’t remove them from the safe subset, as we found a communicative advantage in using them (see the next section before
).
DOs:
Only use them inside the context that is the direct parent of
it
s and have the exercise and verification phasesAlways, always, use
let!
DON’Ts:
- Never use
let
before
before
and let!
are both useful for defining stuff that belongs in the test setup. But how can we decide when to use one or the other? We use these simple rules for deciding:
Use
let!
s for pieces of setup that you need a handle to. That is: variables that are directly used in theit
s (exercise and verification phase).Use
before
s for uninteresting things that need to be there but are not used directly in the test
The idea is to minimize the distracting bits of the test setup and make it evident if the test is fulfilling its description.
it
Finally! it
s hold the exercise and verification phases.
DOs:
Always make them the leaves of the
context
s treeuse a blank line to visually separate the two phases
DON’Ts:
- Don’t use them in the middle of the tree.
Final example
After all these points, arguments, DOs, and DON’Ts, how does a test using these guidelines look like? see for yourself:
describe "Creating a profile" do
context "without providing location id" do
context "when profile does not exist" do
let!(:brand) { create(:brand) }
let!(:user) { create_user(brand: brand) }
let!(:service) { CreateProfileService.new(phone_number: "+155512345678") }
let!(:default_location) { create_location(id: SecureRandom.uuid) }
before do
brand.locations = [default_location]
brand.default_location = default_location
end
it "creates a profile for the default location" do
service.call brand: brand, user: user
expect(Profile.count).to be 1
expect(Profile.last.location).to eq brand.default_location
end
end
context "when profile exists with phone number within a Brand" do
let!(:brand) { create(:brand) }
let!(:user) { create(:user, brand: brand) }
let!(:service) { CreateProfileService.new(phone_number: "+1 555 1234 5678") }
before do
create :profile, phone_number: "+1 555 1234 5678", brand: brand
end
it "does not create a profile" do
result = service.call brand: brand, user: user
expect(Profile.count).to eq 1
expect(result.errors).to be_present
expect(result.errors.first).to eq :profile_already_exists
end
end
end
context "providing a location id" do
context "when the brand does not have a 3rd party integration" do
#...
end
context "when the brand has a 3rd party integration" do
#...
end
end
end
Doesn’t look too alien to my old-time RSpec eyes, what do you think?
Duplication is ok
This might be a little counterintuitive, but having repetition in the test’s setup or verification is ok.
Duplication causes less harm and waste of effort than the previous problems, it helps isolate each test, thus making debugging and finding problems easier when tests fail.
If duplication is not bearable or feels absolutely dumb, extract the repeated bits in carefully crafted Factory Methods (this is not a factory-bot factory, but an actual ruby method like in the factory method pattern).
For example, let’s assume all the test cases for a given service object need a User (that belongs to a Location which belongs to a Brand), and has a specific role on a Group inside that Location. But some of the tests care about the brand or location and Some don’t. But no test in this file cares about the group or the role. You can have a method defined inside this file as:
describe "Creating a user" do
#...
def create_user(brand: create(:brand), location: create(:location, brand: brand))
create(:user).tap do |u|
u.set_role :standard, create(:group, location: location)
end
end
end
This allows us to keep all the benefits of the above guidelines, and at the same time, clean up the little repetitive bits of complex test setups that are not interesting for the test cases at hand. But it is not the “global” unique way to create a User, is specifically tailored for the tests in this test file, thus the impact of the shared logic is contained.
Now, You might be thinking… Why not an actual factory? we’ll see in the next section
About factories
As mentioned before, using a full-featured factory library like factory_bot, gives us another way to obscure our test setups, move them out of the test context, and make them easily fall into a global shared fixture. Similarly to let
and let!
these libraries provide a DSL that replaces method calls and object instantiation. We still use them, because:
team’s culture
DSL is comfortable
But we follow these guidelines strictly:
Keep your factories to the minimal expression possible. Just make them pass validations.
If you are building a bunch of complex relations in your factories, stop and think if you are not moving test setup inside factories.
If you are, then:
Don’t put test setups inside factories
Use factory methods specially crafted for constructing such relations defined as instance methods in the spec file
The purpose of factories is not to define the needed relations in test setups but to hide uninteresting details for the current test about certain needed objects. Factories help to have those unimportant details defined with valid data and avoid polluting the information space of the test with a noisy setup.
For example:
describe "Creating a profile" do
context "without providing location id" do
context "when profile does not exist" do
let!(:brand) { create(:brand) }
let!(:user) { create_user(brand: brand) }
let!(:service) { CreateProfileService.new(phone_number: "+155512345678") }
let!(:default_location) { create_location(id: SecureRandom.uuid) }
before do
brand.locations = [default_location]
brand.default_location = default_location
end
it "creates a profile for the default location" do
service.call brand: brand, user: user
expect(Profile.count).to be 1
expect(Profile.last.location).to eq brand.default_location
end
end
Here the test doesn’t care about the brand’s or user’s name, but it does care about the relation between the two (a user in that brand). Without factories, this setup would look like:
let(:brand) { Brand.create(name: "My Test Brand", address: "742 Evergreen Terrace, Springfield", type: :multi) }
let(:user) { User.create(brand: brand, name: "My user name", password: "admin1234", password_confirmation: "admin1234", role: :admin, email: "admin@my-email.com") }
let(:location) { Location.create brand: brand, name: "My Test Location", address: "742 Evergreen Terrace, Springfield, Again", another_uninteresting_field_for_this_test: true, but_needed_to_create_a_new_instance_of_this_class: true }
let(:group) { Group.create name: :department, location: location}
before { user.add_role :admin, group }
end
To figure out the relationship between the user and the brand, you would need to read all of this and connect the dots in your head. And to learn what are the interesting bits of setup, you need to read the whole test, which you’ll do anyway, but a little explicitness up front makes it way better.
Be kind to your reviewers. Be kind to your future self
About shared Examples
- Don’t
Abstracting away the test’s exercise and verification phases ends up with highly-coupled and anemic tests. Other things benefit from an abstraction or a DRY-up refactoring. In general, tests don’t.
About subject
Avoid using subject
when other ways to express which is the Subject Under Test are available. This falls along the lines of “prefer explicit vs implicit”.
Use more Ruby! <3
Ruby’s Object Orientation, methods, and classes are fantastic tools, super-expressive, and available everywhere, in rspec specs too. You don’t need to force yourself into the DSL. Use what it is good for, leave the rest out of your toolbox.
Always have a critical eye on the things you learned and don’t be afraid of revisiting your principles. You might learn or discover something new or unseen that better fits your team’s culture and brings benefits in the long run!