Innovation rarely happens in a vacuum. It usually starts with an individual brave enough to contribute an idea and a team inspired enough to make it great. This blog provides a forum for all Centrons to contribute ideas, make suggestions, ask questions and inspire others. There are no boundaries. To participate, all you need is the desire to build great products.

RSpec and Dynamic Scoping

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 let declaration.

Purpose and Syntax of a let Declaration

let is syntactic sugar for defining variables for test data.

describe Vendor do
  let(:name) { 'Foobar, Inc.' }
  let(:status) { :ok }
  let(:vendor) { Vendor.new(name: name, status: status) }
  describe '#billable?' do
    it 'is billable when ok' do
      expect(vendor).to be_billable
    end
  end
end

A let declaration consists of a variable and an initialization block. The let variable can be used in other let declarations as well as in before, after, and it blocks; a let variable is used as if they were a normal variable.

A Cool RSpec Tip

The let semantics allows me to alter the value for a let variable in different contexts.

describe Vendor do
  let(:status) { :ok }
  let(:vendor) { Vendor.new(status: status) }
  describe '#billable?' do
    context 'when status is ok' do
      let(:status) { :ok }
      it 'is billable' do
        expect(vendor).to be_billable
      end
    end
    context 'when status is in-default' do
      let(:status) { :in_default }
      it 'is not billable' do
        expect(vendor).to_not be_billable
      end
    end
  end
end

At the top of the describe block where I define vendor, I define let variables for the vendor’s attributes so that vendor is a valid Vendor object.

I can redefine let variables to affect the examples (i.e., it blocks) in each context. A 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 context when vendor is initialized with status set to :in_default.

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 declaration of vendor. Variables don’t normally work this way.

Consider this contrived rewriting of the code:

class LexicalScope
  def describe
    status = -> { :ok }
    vendor = -> { Vendor.new(status: status.call) }
    context(vendor)
  end
  def context(vendor)
    status = -> { :in_default }
    it_is_not_billable(vendor)
  end
  def it_is_not_billable(vendor)
    expect(vendor).to be_billable
  end
end
LexicalScope.new.describe  # FAILS!

let variables are now local variables; the initialize blocks are now lambdas. Each describe and context is a method. This breaks the code. The expectation fails.

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 redefined.

Global scope is the easiest way to implement dynamic scope.

class GlobalScope
  def describe
    $status = -> { :ok }
    $vendor = -> { Vendor.new(status: $status.call) }
    context
  end
  def context
    $status = -> { :in_default }
    it_is_not_billable
  end
  def it_is_not_billable
    expect($vendor).to be_billable
  end
end
GlobalScope.new.describe  # PASSES!

This code is now at the mercy of any other code that uses $status and $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. 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 let variables.

Dynamic Ruby

Ruby is dynamic with its methods. Consider this code:

entities.all? { |e| e.billable? }

What billable? method gets called? It depends on each value of e at runtime. It could be Vendor#billable? or Publisher#billable? or 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:

class VendorDescribe
  def status
    :ok
  end
  def vendor
    Vendor.new(status: status)
  end
end
class BillableContext < VendorDescribe
end
class InDefaultContext < BillableContext
  def status
    :in_default
  end
  def it_is_not_billable
    expect(vendor).to_not be_billable
  end
end
c = InDefaultContext.new
c.it_is_not_billable  # PASSES!

The describe and contexts become classes; nested contexts become subclasses. let variables are defined as instance methods.

Consider executing VendorDescribe.new.status. The method is applied to an instance of VendorDescribe which defines status, so that’s the version that gets executed. :ok is returned.

Consider executing c.status. Since c is an instance of InDefaultContext, execution is dispatched to InDefaultContext#status, and we get :in_default back.

Now consider executing c.vendor. Since 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 definitions of status.

Keep in mind, we’re calling status on 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 InDefaultContext and evaluates to :in_default.

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 scoping of let variables.

It also provides lazy evaluation of the let variables. let initializations are evaluated only if you actually refer to the let 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 calling 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:

def vendor
  @vendor ||= Vendor.new(status: status)
end

The Vendor is memoized in the @vendor instance variable. Calling the method the first time computes the Vendor; calling it a second time returns the existing value.

However, this approach is less than optimal if the computation to initialize @vendor returns nil or any falsey value. As long as @vendor remains 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:

def initialize
  @cache = {}
end
def vendor
  @cache.fetch(:vendor) { |k| @cache[k] = Vendor.create!(status: status) }
end

If :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:

class VendorDescribeDeclarations
  def status
    :ok
  end
  def vendor
    Vendor.new(status: status)
  end
end
class VendorDescribe < VendorDescribeDeclarations
  def initialize
    @cache = {}
  end
  def status
    @cache.fetch(:status) { |k| @cache[k] = super }
  end
  def vendor
    @cache.fetch(:vendor) { |k| @cache[k] = super }
  end
end

Notice the patterns that jump out for the let declarations. Strong, simple patterns like this means it’s much easier to metaprogram a solution1.

If we had examples in the top describe, they would be defined as methods in VendorDescribe, and they would get exactly the definitions of status and vendor that they need.

Each context is similar:

class BillableContext < VendorDescribe
end
class InDefaultContextDeclarations < BillableContext
  def status
    :in_default
  end
end
class InDefaultContext < InDefaultContextDeclarations
  def status
    @cache.fetch(:status) { |k| @cache[k] = super }
  end
  def it_is_not_billable
    expect(vendor).to_not be_billable
  end
end
c = InDefaultContext.new
c.it_is_not_billable  # PASSES!

Recap

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.

  1. You do not want to metaprogram the instance-variable solution above.