In RSpec you can accomplish the same âsurface behaviorâ - which I define as behavior that results in the spec passing -
using any number of different combinations of memoized helpers (most commonly, let
and let!
) and before/after hooks.
Something set up in a block via before
can be asserted the same as something set up in a memoized helper, for
instance.
When writing very simplistic spec it can mostly be a matter of semantics, but for more complex spec any ambiguity of these constructs can lead a newer developer to some frustrating test failures (caused by the spec, not the code the spec is testing) and a mountain of âtechnical [spec] debtâ. Yes. Thatâs a thing. Iâve seen codebases with really bloated and awful spec which made adding new tests difficult and time consuming.
So letâs throw ambiguity out the window and take a look at the differences between them. The documentation for these memoized helpers and before hooks isnât bad; but letâs go over them quickly and then review a bunch of code highlighting these differences.
It should be noted that these arenât specific to RSpec. Minitestâs setup
is pretty synonymous with before
, for
example. Also, Minitest can and does support the same sort of behavior with itâs MiniTest::Spec
, an RSpec-like module
that implements most of these concepts.
before
before
and its sibling after
are RSpecâs way of supporting common setup and teardown. Itâs simply a block of code
run before or after each example (by default :example
is used; you can also specify :context
or :suite
instead). Instance
variables declared in before(:example)
are accessible within each example group in the current example group (the block you
wrote using context
or describe
).
let
let
defines a memoized helper method. This means that the value of a call to this method will be cached across
multiple calls in an example (it
, example
). Itâs not cached across examples. let
is lazy-evaluated, which
means that the expression within the block is not evaluated until the defined method is called for the first time.
So, your let
method is only evaluated if and when you call the method in your example and the value it returns is
cached and wonât need to be computed again.
let!
let!
is the same as let
except your method is automatically called in a before
hook.
Using these three methods, you can create the âsimilar surface behaviorâ I mentioned:
describe "using let" do
let(:a_thing) { code_that_creates_a_thing }
before do
a_thing
end
it "tests something here..."
end
describe "using let!" do
let!(:a_thing) { code_that_creates_a_thing }
it "tests something here..."
end
describe "using a before hook" do
before do
code_that_creates_a_thing
# You can also assign the thing to an instance variable
end
it "tests something here..."
end
All three of the examples can assert the same sort of thing. So what are the differences?
1. Execution order
An example of execution order:
# Used for properly intended output
FOO = " FOO"
BAR = " BAR"
describe "before(:each) and memoized helpers" do
context "before(:each)" do
before(:each) do
puts FOO
end
example "FOO appears before BAR" do
# because it's called within a before hook
puts BAR
end
end
context "let (lazy-eval)" do
let(:memo) do
puts FOO
end
example "FOO never appears" do
# because `memo` is never called in this example
puts BAR
end
example "FOO appears after BAR" do
# because I called it that way in this example
puts BAR
memo
end
example "FOO appears before BAR" do
# because I called in that way in this example
memo
puts BAR
end
end
context "let!" do
let!(:memo) do
puts FOO
end
example "FOO appears before BAR" do
# because the memo gets run as a before hook with let!
puts BAR
end
end
context "let with let!" do
let!(:memo) do
another_memo
puts FOO
end
let(:another_memo) do
puts " BAZ"
end
example "FOO appears after BAZ but before BAR" do
# because `memo` calls `another_memo` in its block
# and is called within a before hook
puts BAR
end
end
end
Output with `--format documentation`:
before(:each) and memoized helpers
before(:each)
FOO
BAR
FOO appears before BAR
let (lazy-eval)
BAR
FOO never appears
BAR
FOO
FOO appears after BAR
FOO
BAR
FOO appears before BAR
let!
FOO
BAR
FOO appears before BAR
let with let!
BAZ
FOO
BAR
FOO appears after BAZ but before BAR
Note that the âbefore(:each)â and âlet!â examples are essentially identical. Thatâs because they are (in terms of call order).
Use
let!
to define a memoized helper method that is called in abefore
hook
The documentation is great, but itâs something that seems to confuse people often. You can go spelunking in RSpec to prove it to yourself:
def let!(name, &block)
let(name, &block)
before { __send__(name) }
end
2. Memoization and lazy-evaluation
We talked about this one already. The helpers have both of these things, a simple before
block does not.
3. Overriding helpers in lower contexts
You canât âcancelâ or override a hook in a parent example group. You can, however, override (or reassign) a memoized helper.
describe "Person#greet_chris" do
subject(:greeting) { person.greet_chris }
let(:person) { create(:person) }
it "returns a default greeting" do
expect(greeting).to eq "Hi, Chris!"
end
context "as a musical student of mine" do
let(:person) { create(:person, type: 'student') }
it "returns a more formal greeting" do
expect(greeting).to eq "Hi, Mr. Arcand!"
end
context "who is scared because they didn't practice" do
let(:person) { create(:person, type: 'student', hours_practiced_for_lesson: .25) }
it "returns a terrified greeting" do
expect(greeting).to eq "Hello Mr. Arcand. I brought you a burrito..."
end
end
end
end
In this example I use FactoryGirl methods to demonstrate creating different types of person
to #greet_chris
.
Iâm testing that my person
object addresses me as Iâd expect, given their type or mood - and I can easily change the
person
for a different context and donât need to constantly rewrite what greeting
is.
If youâre wondering, for the purposes of this post subject
works exactly the same as let
.
Conclusion
My examples here might be trivial but in real world practice it makes all the difference. You might be running end to
end tests that are expecting sample data to already be loaded in the database, and forget that your memoized dummy
creation is never actually run. In doing Rails controller testing, I find that many people fall victim to not realizing
when exactly a request is made if conveniently DRYâd up in a helper - some tests make assertions on the response
(expectation after request is made) and some make assertions on an change of state (expectation before a request is
made). Confusion might ensue over how to efficiently override setup code in specific contexts because someone else
just threw a bunch of code in a top-level before
block.
Having complete control over these constructs is crucial in writing clean and efficient tests. Youâll discover that if you take the time to put the same quality in your tests as in your code, future you and the other developers on your team will really love you for it.