When we write clean Ruby code, we try to pull out methods with descriptive names that do small amounts of work. It’s possible to do the same in RSpec, just as we would in a less “fluent” test framework like Ruby’s standard testing library Minitest.
RSpec’s describe
and context
methods define anonymous classes that behave like any other Ruby class as far as scope. Nested blocks even inherit methods from their containers and can use super
. Each it
block is more like a method, creating an instance of its outer describe
/ context
and executing in that scope.
With this, we can extract pieces of logic, share them between multiple specs, give them descriptive names, and call them from within it
blocks. This leads to descriptive tests that don’t suffer from the Mystery Guest problem: when reading tests, we can’t understand “the connection between fixture and verification logic because it is done outside of the test method.”
This code smell is often introduced by using before
or let
in RSpec tests:
RSpec.describe PlayerCharacter do
subject { PlayerCharacter.new }
context "rogue" do
subject { PlayerCharacter.new.tap { |pc| pc.add_level(:rogue) } }
it "has sneak attack" do
expect(subject).to have_sneak_attack
end
end
end
“Isn’t this WET?”
An argument I’ve often heard against this type of approach is that it leads to longer, more complex, less DRY tests. This is a misunderstanding of the problem!
There is a smell associated with complex test setup: generally speaking, if a system is difficult to test, it is overly complex. Usually this is because it has many collaborators, does too many things, or violates the Law of Demeter.
What about let
?
RSpec loves let
and its other DSL methods. It’s a shortcut to writing a method, which is part of why defining methods explicitly works. But let
is not Ruby, and using it is an unnecessary abstraction. Defining a method is a little bit longer, but it is clearer to the reader what is happening with a method than with a let
, some complex before
block that isn’t referenced, a shared_context
, etc. For one-liners such as let
is meant to facilitate, it’s also ~13% longer to write let(:rogue) { create(:rogue) }
than it is to use Ruby 3’s new endless method syntax: def rogue = create(:rogue)
.
Nine times out of ten, clarity beats brevity when writing code. pic.twitter.com/fiyjPWSx1z— Dare Obasanjo (@Carnage4Life) May 27, 2022
Rather than hiding the setup in a Cambrian explosion of before
, around
, let
, let!
, subject
, etc., it is beneficial to have this setup as part of the test method. Extracting named methods maintains the benefit here because they are explicitly included and therefore are no longer a mystery.
Extract methods from specs
Writing methods in RSpec is pretty easy, but there are a couple of “gotchas”: polluting the global scope and trying to define methods within it
blocks.
We want to avoid defining methods in the global scope so there is no chance of redefining something available in our app, either globally or because of scope within a class. Instead, be sure to write them inside the describe
or context
block that allows all tests needing the method to access it without providing the method to additional tests. Sometimes it makes sense to build up a new grouping of tests that need to share the method, and other times it is easiest to just write them into the outermost describe
block.
I’ve also made the mistake a few times of trying to write methods inside of it
blocks, which is akin to writing methods inside of methods. Make sure that helper methods are defined outside of it
.
As you can see below, we’re able to define rogue
as a helper for the entire context "rogue"
block, then override it and call super
in a child context
because of the class inheritance we talked about earlier. The rogue
method itself is defined in terms of the even broader pc
helper that can be shared with the snipped specs for other player character classes.
RSpec.describe PlayerCharacter do
context "rogue" do
it "has sneak attack" do
expect(rogue).to have_sneak_attack
end
context "at level 6" do
it "has expertise" do
expect(rogue).to have_expertise
end
def rogue
super.tap do |r|
5.times { r.add_level(:rogue) }
end
end
end
def rogue
pc(:rogue)
end
end
# (snip specs for other classes...)
def pc(*levels)
pc = PlayerCharacter.new
levels.each { |l| pc.add_level(l) }
pc
end
end
As with a class, I prefer to define these helper methods below the tests that use them. Unlike in a class, I recommend not extracting methods to service objects. We want to highlight complexity in our test setup so that we feel the pain of it—and have a desire to reduce that complexity either when writing our tests or later when reading them.
Sharing code between specs
That said, if a method is going to be useful across multiple systems under test, it does make sense to extract those methods into modules under spec/support/
. This is because RSpec requires all files in that directory by default, and we can include
them into all specs as part of spec_ or rails_helper.
RSpec.configure do |config|
config.include HelperModule
end
module HelperModule
def pc(*levels)
pc = PlayerCharacter.new
levels.each { |l| pc.add_level(l) }
pc
end
end
Bonus: Tooling Compatibility
While some IDEs are able to make an attempt at identifying where a let
method is defined, most aren’t. Defining real methods will allow tools like universal-ctags, GitHub’s source popup links, etc. to identify where the method is defined and let you quickly navigate to them with tools that use tags files like Vim’s ]
jump to definition command.
Conclusion
Extracting shared setup from RSpec tests with methods helps us to build up a well-documented, mystery-free, clean suite of tests. They’re easy to define, easy to scope, and are a better practice to use than RSpec’s inbuilt tools like before
, subject
, and let
.