RSpec DSL Is Just Fine
In a recent article, Caleb Hearth proposes improving RSpec specifications by using ruby methods. I don’t agree with his prescription.
The original motivation for the article is to avoid the Mystert Guest problem. I agree with the intention: Having clear and legible specs is a great goal and is an integral part of a maintainable system.
Let’s examine the author’s spec, after it has been refactored to his liking, and using standard Ruby’s method for setup. I copy and pasted the code from the original article at the time of writing:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
There are things that bother me about this example. The first is the mix between RSpec’s DSL (e.g. describe
, context
) and standard ruby (e.g. def ...
). I think this adds a burden to the reader to keep top-of-mind that RSpec defines anonymous classes. In fact, the author explains this in the second paragraph of the article.
My second objection is that this code example doesn’t accomplish what it set out to do: Remove the mystery guest!. Moreover, it actually moves the setup of the test far away from the test itself.
There are two assertion in this file. The one on line 4, and the one on line 9. To figure out how rogue
is defined in line 4, I have to scroll to line 19 and see the method definition. Now, that depends on pc
, which in turn is defined in line 26 (which by the author’s own admission would be actually farther away, since more specs were snipped).
The second assertion is even worse. To know how rogue
comes from in line 9, I first have to visit the definition on line 12, which in turn calls super
. that takes me back to line 19, and then to 26. Even worse, the definition of rogue
on line 12 has a mystery guest of it’s own: The 5
in line 14. Where does that come from? The context for the spec calls for “at level 6”. Is the 5
an error? It’s not clear until we trace the fact that super
has produced a rogue
already at level 1
, and we need to add 5
more to get to 6
. All this indirection requires the reader to keep in mind the bit about RSpec’s anonymous classes.
Now, I am not arguing that using RSpec DSL means that your specs are always going to be crystal clear. I argue that it’s quite possible to create legible specs with the DSL. Here is the same spec, as I would write it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
RSpec.describe PlayerCharacter do
context "rogue" do
subject { PlayerCharacter.new.tap { |pc| pc.add_level(:rouge) } }
it "has sneak attack" do
expect(subject).to have_sneak_attack
end
context "at level 6" do
let(:level) { 6 }
subject { PlayerCharacter.new.tap { |pc| level.times { pc.add_level(:rouge) } } }
it "has expertise" do
expect(subject).to have_expertise
end
end
end
end
The rewritten spec is readable from top to bottom. The subject
is introduced close to each context. In particular, I would point the reader to the “at level 6” context. The very next line, introduces a level
that is hard-coded at 6. It should be painly clear where that comes from, and that it’s particularly relevant to this context. The next line will use it to setup the subject
.
Another argument made in the original article is that:
But let is not Ruby, and using it is an unnecessary abstraction.
I don’t quite follow why the line is drawn at let
. The author seems quite happy to use other RSpec abstractions like have_sneak_attack
and have_expertise
. Since there is no mention of custom matchers, I assume that it is reliant on RSpec built-in predicate matchers. A spec in the form of have_xxx
passes when the subject’s have_xxx?
method returns true. I personally find that abstraction much harder to internalize than a let
or subject
declaration.
The author also mentions that using ruby methods makes existing tooling work better. While it’s true that my tooling doesn’t understand let
and subject
declarations, I don’t see how having nested methods calling super
on each other improves the situation. When trying to jump to the definition, my tooling would show me all possible definitions. I would still have to choose which one to jump to. How would I know which one is the correct one? Even if I did, the super
means that they would need to jump to all of them anyway.
Conclusion
I believe that the code presented as an example of a good spec by the author can be much improved by using RSpec existing DSL. The example is clearly not as complex as specs in most production code bases, but I stuck with it because that is what the author used.
In practice, I’ve seen many specs that stick to RSpec’s DSL and are very hard to follow, have mystery guests, and are generally painful to use. I don’t blame the DSL or think that using methods would immediately improve the situation. Clear, legible specs are possible if they are written intentionally. Collaborators should be defined close to where they are used. Clarity beats brevity. Some repetition in test setup is fine. RSpec DSL is fine, too.
Find me on Mastodon at @ylansegal@mastodon.sdf.org,
or by email at ylan@{this top domain}
.