RSpec is a popular testing framework for Ruby. It offers
a lot of syntactic sugar to make writing specs (i.e., tests) easier and more
expressive. One of my favorite features is the
Purpose and Syntax of a
let is syntactic sugar for defining variables for test data.
let declaration consists of a variable and an initialization block. The
let variable can be used in other
let declarations as well as in
it blocks; a
let variable is used as if they were a normal
A Cool RSpec Tip
let semantics allows me to alter the value for a
let variable in
At the top of the
describe block where I define
vendor, I define
variables for the vendor’s attributes so that
vendor is a valid
I can redefine
let variables to affect the examples (i.e.,
it blocks) in
context then becomes a complete story:
- When the status is “ok”, the vendor is billable
- When the status is “in default”, the vendor is not billable
This Is Strange
Consider the second
vendor is initialized with
status set to
The passing spec proves that
status has been altered. So somehow the “is not
billable” example uses the version of
status that is declared after the
vendor. Variables don’t normally work this way.
Consider this contrived rewriting of the code:
let variables are now local variables; the initialize blocks are now lambdas.
context is a method. This breaks the code. The
status used to initialize
vendor is defined on the previous line in
describe. Local variables are bound using lexical scope.
You look at the text of the code to figure out where a variable was defined and set. You need to look only in the method and on previous lines in that method.
This is not how the
let variables are working. They’re using dynamic
scope. Instead of looking at the text of a program to figure out where a
variable was declared, you consider the most recently executed code. In the
“not billable” example,
vendor is evaluated after
status has been
Global scope is the easiest way to implement dynamic scope.
This code is now at the mercy of any other code that uses
$vendor. Imagine the nightmare if all of your variables were global. You get
this same nightmare from dynamic scope. This is why Ruby (and most languages) do
not provide dynamic scope. Dynamic scope is hard to reason with.
let variables, though, are much more controlled and isolated. Each example is
run independently of the others with fresh
let variables are
also kept separately from other Ruby variables.
But if Ruby doesn’t support dynamic scope, RSpec must be simulating it for its
Ruby is dynamic with its methods. Consider this code:
billable? method gets called? It depends on each value of
runtime. It could be
Service#billable? or any
#billable? defined on any class; it could be a
different one on each iteration.
This is whole point of object-oriented programming: let the objects determine what code to run at runtime. This is known as dynamic dispatch.
Consider this code:
contexts become classes; nested
let variables are defined as instance methods.
VendorDescribe.new.status. The method is applied to an
VendorDescribe which defines
status, so that’s the version that
:ok is returned.
c is an instance of
execution is dispatched to
InDefaultContext#status, and we get
Now consider executing
InDefaultContext does not define
vendor, execution looks for the method in a superclass and finds
VendorDescribe#vendor. This method, in turn, calls
status. But we have two
Keep in mind, we’re calling
c. You don’t look at the text of the
program to find the definition of
status; you look at the data at runtime.
As we already discovered,
c.status uses the definition in
and evaluates to
So we get
Vendor.new(status: :in_default) which is not billable and the
example passes. Mission accomplished!
Lazy Evaluation and Memoization
There are a variety of reasons why the
let declarations use a block to wrap up
the initializations. As outlined above, it allows RSpec to simulate dynamic
It also provides lazy evaluation of the
initializations are evaluated only if you actually refer to the
variable—they’re lazy. If a particular example does not use a particular
let variable, RSpec doesn’t waste time trying to initialize it.
However, consider what happens if an example calls
vendor three times. As
written above, we’d create a new
Vendor each time. The assumption is that
vendor returns the same object each time in one example, so we’d
probably end up with failing specs.
We could memoize the result. You may have seen this pattern:
Vendor is memoized in the
@vendor instance variable. Calling the method
the first time computes the
Vendor; calling it a second time returns the
However, this approach is less than optimal if the computation to initialize
nil or any falsey value. As long as
falsey, each call to
vendor will trigger a re-evaluation of its initializing
expression. We want to avoid that.
We could, instead, save the values in a cache:
:vendor is already in the cache,
fetch will just return that value. If
it is not in the cache, then
fetch evaluates its block which evaluates the
initialization, stores it in the cache, and returns the value.
RSpec does something pretty smart and separates out the “compute the value” from “cache the value” responsibilities. We end up with something more like this:
Notice the patterns that jump out for the
let declarations. Strong, simple
patterns like this means it’s much easier to metaprogram a
If we had examples in the top
describe, they would be defined as methods in
VendorDescribe, and they would get exactly the definitions of
vendor that they need.
context is similar:
RSpec’s solution to dynamic scope, lazy evaluation, and memoization is rather slick and surprisingly clean. One key to this is that dynamic scope is easily implemented with dynamic dispatch; the other key is that it can be hidden cleanly with metaprogramming.
I love the power dynamic scope gives me in my specs.
- You do not want to metaprogram the instance-variable solution above.