Inline setup versus let in RSpec

let has a long history in RSpec having been introduced with a commit in 2009 and rolled out as part of RSpec 2. I adopted it early on as the aesthetics appealed to me with let variables being defined first in little blocks with symbols. One of our engineers wrote a macro in vim to quickly hoist instance variables in older style RSpec tests to let declarations. It became the default style for everyone in RSpec with only a bit of controversy around let! which could be easy to miss in tests. I knew about the lazy initialization, but it was the aesthetics I prized.

I remember a bit of a community debate a few years later with a famous Thoughtbot blog post entitled Let’s Not. It argued pretty convincingly that let was a mystery guest pattern which you don’t want when writng clear tests. Since our team’s default at that point was to use let, but we wrote pretty small Sandi Metz style classes with a single responsibility, the lets weren’t causing a big headache. If the code and the specs were visible in a single editor window, it was easy to see what was going on.

Fast forward to working on one of the largest Ruby codebases in the world and I got reintroduced to the idea of containing the entire test inline. Much of the codebase doesn’t fit in a single editor window and suddenly those little lets are screens away and hard to find. Add with nested contexts, shared examples and the like I was really hating let. Some of the more painful specs potentially executed up to 100 lets before running an individual spec and often fired hundreds of SQL queries. I continued to use it on newer refactored code, but eventually the team started to cut back to allowing fewer lets in a given spec as a compromise. Today my default is to write the entire context of the test inline with no lets at all.

So a let heavy spec like the following is forced into:

RSpec.describe BlogPost do
  let(:author) { Author.new(name: "Gunther Hemingway") }
  let(:editor) { Editor.new(name: "Jordon Adams") }
  let(:category) { Category.new(name: "technology") }
  let(:comments) do
    [
      Comment.new(author: "hank", content: "great post!"),
      Comment.new(author: "lisa", content: "very informative."),
    ]
  end
  let(:tags) { ["ruby", "software development", "tdd"] }
  let(:blog_post) do
    BlogPost.new(
      title: "rspec and let",
      content: "using let in rspec can help with...",
      author: author,
      editor: editor,
      category: category,
      comments: comments,
      tags: tags
    )
  end

  describe "#publish" do
    it "notifies the author and editor when published" do
      blog_post.publish

      expect(author.notifications.last).to eq("your post 'rspec and let' has been published.")
      expect(editor.notifications.last).to eq("the post 'rspec and let' you edited has been published.")
    end
  end
end

This much more explict style:

RSpec.describe BlogPost do
  describe "#publish" do
    it "notifies the author and editor when published" do
      blog_post = BlogPost.new(
        title: "rspec and let",
        content: "using let in rspec can help with...",
        author: Author.new(name: "Gunther Hemingway")
        editor: Editor.new(name: "Jordon Adams")
        category: category.new(name: "technology"),
        comments: [
          comment.new(author: "leslie", content: "great post!"),
          comment.new(author: "bob", content: "very informative.")
        ],
        tags: ["ruby", "software development", "tdd"]
      )

      blog_post.publish

      expect(blog_post.author.notifications.last).to eq("your post 'rspec and let' has been published.")
      expect(blog_post.editor.notifications.last).to eq("the post 'rspec and let' you edited has been published.")
    end
  end
end

Inline default leads to several happy impacts:

  • If you ever need to move this spec, copy it as a starting point, etc, it’s all intact as a single unit
  • Sure you’ll probably duplicate some of this for a second spec, but deep in a spec file you’ll never need to scroll around to see what’s going on
  • If your setup code looks like many lines of boilerplate perhaps your class has way to many dependencies and needs reactoring

So if you haven’t tried this recently I’d invite you to do an experiment for a week and write zero lets.