RSpec and let by jgn on Sunday, October 2, 2011 in Ruby and Code

So here's a good one. We had a spec that looked something like this:

[code language='ruby'] require 'time'

class SomeModel def self.all @all ||= [] end def self.create!(time) all

(In the original code, rather than check that the .all method returned a enumeration with 3 items, we were describing the behavior of a different object to see if it could do something when three collaborating objects had already been created via factories.)

The spec told us this:

[code language='ruby'] Failure/Error: its(:all) { should have(3).items } expected 3 items, got 1

That's funny. How come we don't have three items? Let's try a bit of puts debugging. We'll dump the value of hour just inside the block passed to #each. And we get:

[code language='ruby'] hour: 9 AM hour: 10 AM hour: 11 AM

Well that's expected. Gee, maybe our collaborator is buggy (and maybe we should be mocking it anyway, but let that pass). Let's show the hour inside of what the let defines:

[code language='ruby'] ['9 AM', '10 AM', '11 AM'].each do |hour| puts "hour: #{hour}" let!(:time) { puts "hour inside time: #{hour}"; Time.mktime(now.year, now.month, now.day, Time.parse(hour).hour) } let!(:some_model) { SomeModel.create!(time) } end

What do we get?

[code language='ruby'] hour: 9 AM hour: 10 AM hour: 11 AM hour inside time: 11 AM F

Interesting.

The problem is that we hadn't internalized properly the documentation for the RSpec let (and let!) helper. The documentation says: "Use let to define a memoized helper method. The value will be cached across multiple calls in the same example but not across examples. Note that let is lazy-evaluated: it is not evaluated until the first time the method it defines is invoked. You can use let! to force the method's invocation before each example" ( https://www.relishapp.com/rspec/rspec-core/docs/helper-methods/let-and-let).

The key phrase is "define a . . . method." So what our code is doing above is simply redefining the #time and #some_model methods over and over. Then, since the execution is deferred, we would get the #time method returning '11 AM' and the #some_model method instantiating only one SomeModel.

Before we touch on one way to fix this, let's look at some of the implications of what let does. For one thing, let's remember that the method will only be executed once (since it's memoized). For example:

[code language='ruby'] describe "no surprise" do let(:x) { puts "x defined to return 'a'"; "a" } let(:x) { puts "x defined to return 'b'"; "b" } it "should have the expected setup value" do x.should == "b" x.should == "b" end end

When this spec is run, we are going to see "x defined to return 'b'" once. We don't see "x defined to return 'a'" because the execution is deferred, and the second let(:x) wipes out the first. Since the result is memoized, we only see "x defined to return 'b'" get printed out once:

[code language='ruby'] $ rspec spec/example2_spec.rb x defined to return 'b' .

Finished in 0.00034 seconds 1 example, 0 failures

Additionally, since let defines methods, and since execution of the methods is deferred until after they have all been defined, you can have earlier methods depend on later methods:

[code language='ruby'] describe "no surprise" do let(:x) { y + 1 } let(:y) { 1 } it "should not matter in what order these methods are defined" do x.should == 2 end end

So why the confusion? I think there are a couple of reasons. The main thing is that word "let." It sounds as though we are assigning a value to a key. It is not uncommon to see sequences of lets like so:

[code language='ruby'] let!(:last) { Factory(:user, last_name: 'zzz', first_name: 'aaa') } let!(:center) { Factory(:user, last_name: 'aaa', first_name: 'abc') } let!(:first) { Factory(:user, last_name: 'aaa', first_name: 'aabc') }

So let looks something like an assignment. Why is it called let? I don't know. Maybe it's supposed to feel a bit like Common Lisp where let is a stand-in for lambda. Maybe the thought was that if "assigns" or "set" was used, it would be ambiguous given how these terms are used elsewhere in Ruby and Rails.

If you ask me, a name such as "dm" (for define_method) would have been more appropriate. Then noobs (like me -- I've always used Test::Unit) would wonder what it stands for, look it up in the documentation, and get some insight. "let" is just too abstract.

The original problem? If we must use let, perhaps define an Array with the let:

[code language='ruby'] describe "surprise" do subject { SomeModel } let!(:now) { Time.now } let!(:some_models) do ['9 AM', '10 AM', '11 AM'].map do |hour| SomeModel.create!(Time.mktime(now.year, now.month, now.day, Time.parse(hour).hour)) end end its(:all) { should have(3).items } end

One last thing. Discussions in books are a bit misleading, or don't tell the truth soon enough. For instance, in Noel Rappin's book Rails Test Prescriptions (which is not only excellent but actually funny), he introduces let by saying: "Using let(), you can make a variable available within the current example group[.] . . . The symbol can then be called as if it was a local variable" (p. 193). Well, this is true, but by saying that "you can make a variable available," it sounds like variable assignment -- which it just isn't. Among other things, just saying that makes one thing that the order of the let's is important in terms of the values that will eventually be assigned. Thankfully he says just a wee bit later, "a let() call is syntactic sugar for defining a method and memoizing the the result." Yes. But this is one of those cases where easing into the truth can be harmful.

The RSpec Book (Chelimsky, et al.) does a similar thing, rhetorically: "When the code in a before block is only creating instance variables and assigning them values, which is most of the time, we can use RSpec's let() method instead" (p. 55). Again, this is a bit slippery. It's not like creating instance variables at all. It's like calling a method. Which, of course, Chelimsky immediately explains.

The upshot:

  1. Don't put your calls to let inside of loops! (If you must, have the calls to let define different symbols.)
  2. Don't assume that the methods will run in the order of the lets.
comments powered by Disqus