What data is created when I execute this RSpec test? This question is not as
straightforward as you might think. Spec files that rely on let
often spread
these declarations all over the file so you have to scroll around to find them.
Additionally, let
is lazy so a particular declaration won’t actually be
executed unless it is referenced, either directly by your test or indirectly via
one of the direct references. And just to make things even harder, there is also
let!
which is eager and always executes no matter what! 😱
In these situations, I like to use a visual approach with a dependency graph to understand what is going on.
Our test
Consider a test file like this. It has a mix of let
and let!
defined at
various places in the file and at various levels of nesting. What data is
created for the it "is complete"
and it "is an active product"
tests?
describe User do
let(:organization) { create(:organization) }
let(:user) { create(:user, organization: organization) }
let!(:admin) { create(:user, admin: true, organization: organization) }
# lots of other tests
describe "Invoices" do
let(:product) { create(:product) }
let(:invoice) { create(:invoice, owner: user, items: [product] }
it "is complete" do
expect(invoice).to be_complete
end
it "is an active product" do
expect(product).to be_active
end
end
end
List the lets
The first step is to list the let
declarations in our file. Start near your
test and work outward. This lets us know what we are working with. In our
example we have:
- invoice
- product
- admin
- user
- organization
Draw connections
Next, go through the list and see if the associated block references any other
let
declarations. For example let(:user) { create(:user, organization:
organization) }
references the organization let
. If they do, draw an arrow
pointing to that reference (in this case from user to organization). You should
end up with a diagram that looks like this.
Mark the eager values
Now we can start marking which items get executed. Let’s shade them orange.
let!
is eager - it will always be executed regardless of whether it is
referenced or not. Since admin
is defined with let!
, we can immediately
shade it without looking at the individual tests.
Follow the dependencies
When a let
is invoked, its block might reference other let
s, causing them to
be evaluated as well. To account for this, we find all of our shaded boxes,
follow the outbound arrows, and shade all dependencies. Keep doing this until
we’ve gone as far as we can go.
It’s important not to follow arrows in reverse! Here we follow the arrow from the admin to the organization and shade it. We can’t go backwards from the organization to the user.
Follow lazy dependencies from tests
Now we can actually get to the individual tests. For each test, mark any let
referenced directly in the test. Then, as before, follow the outbound arrows as
far as they will go, marking every box.
For the test it "is complete"
, the final diagram looks like this. Every box is
shaded so all of these lets will trigger. Perhaps that’s more than you expected?
Does this test really need a product?
For the test it "is an active product"
, the diagram looks like this. You can
see that not all of our let
s get executed this time.
Summary
If we streamline the process a bit by merging the two “follow the dependencies” steps together, we end up with the following 5 steps:
- List the
let
s - Draw connections
- Mark eager values
- Mark values in tests
- Follow the dependency arrows
Using this visual approach, I find it much easier to understand what data is and
isn’t created in large, gnarly spec files. It can also be a good teaching tool
for understanding the nuances between let
and let!
.
If all this complexity with let
is frustrating for you, check out Let’s Not
and The Self-Contained Test for a radically different approach to writing
tests that avoids let
altogether.