Context-scope “let” for RSpec

Do you love how RSpec’s let method allows you to DRY up your tests and clean up your code? Do you hate how slow your tests get when they re-run the operation under test over, and over, and over again? Does the conflict between these two feelings cause you to break out in hives?

Well, put away the antihistamines, because rspec-context-let has the answer to your prayers:

require 'rspec-context-let'

describe MyAPI do
  context "when called" do
    clet(:response) do
      MyAPI.call
    end
    
    it "makes a response" do
      expect(response).to_not be(nil)
    end
    
    it "returns a hash" do
      expect(response).to be_a(Hash)
    end
    
    it "returns at least three records" do
      expect(response.length).to be >= 3
    end
  end
end

By wrapping your expensive operations in a clet (“Context LET”) call, rather than a regular let call, the result will be cached for the entireity of that context’s existence, rather than being recalculated for every example. Other than that little detail, a variable set by clet should work pretty much identically to a variable set by let – it’s available in all your examples, it’s available in sub-contexts (and won’t be re-run in those sub-contexts), and it’s available in shared example groups (if you rely on letted variables in there, which I don’t really recommend).

If you’re wondering why this useful, consider what happens if the above MyAPI.call takes, say, 100ms to run. In that case, using clet instead of let has just saved you 200ms every time you run the above test cases. Multiply that by the 15 or 20 examples you might actually have for a given piece of test code, and the dozens or hundreds of times a day you run your test suites, and damn it adds up. As a real-world testimonial, the test suite in which clet was first developed had 405 examples at the time; before clet, it took an average of 20.54 seconds to run; afterwards, it took 7.66 seconds. (Averages taken from 25 runs of each suite on an otherwise idle machine)

Despite the indisputable awesomeness of the above, everything isn’t quite perfection. With the code under test only being run once, anything that gets reset between examples can’t be examined to verify the tested code did the right thing. Using instance variables, for example, probably won’t do what you want:

require 'rspec-context-let'

describe MyAPI do
  context "when called" do
    clet(:response) do
      @prev_value = MyAPI.get_value
      MyAPI.call
    end
    
    it "makes a response" do
      expect(response).to_not be(nil)
    end
    
    it "changes value" do
      # THIS WILL FAIL SPECTACULARLY
      expect(MyAPI.get_value).to_not eq(@prev_value)
    end
  end
end

The problem here is that @prev_value will get set when the block passed to clet runs… which will be for the "makes a response" example. By the time the "changes value" example runs, that instance variable is dead and buried.

Another problem that has bitten me in the past is using DB transactions to clean up database changes after every example:

require 'rspec-context-let'

RSpec.configure do |c|
  c.around do |example|
    DB.transaction(
         :rollback => :always,
         :auto_savepoint => true
       ) { example.run }
  end
end

describe MyAPI do
  context "when called" do
    clet(:response) do
      MyAPI.call
    end
    
    it "makes a response" do
      expect(response).to_not be(nil)
    end
    
    it "does something to the database" do
      # THIS WON'T END WELL EITHER
      expect(DB[:dataz].first.id).to eq("d00d")
    end
  end
end

This fails for much the same reason as the instance variable case. The database got changed when MyAPI.call ran, but at the end of that first example the DB transaction got rolled back and the change was no longer there when the second example ran.

Depending on your circumstances, you should either use a regular let in these circumstances, or else wrap the call to the code under test into the same clet call as captures the data you wish to examine. In the database case, you might do that with:

require 'rspec-context-let'

describe MyAPI do
  context "when called" do
    clet(:response) do
      MyAPI.call
    end
    
    clet(:dbdata) do
      MyAPI.call
      DB[:dataz].first
    end
    
    it "makes a response" do
      expect(response).to_not be(nil)
    end
    
    it "does something to the database" do
      expect(dbdata.id).to eq("d00d")
    end
  end
end