Speed up RSpec tests: understand lifecycle and execution
One of RSpec’s strengths is the legibility of its behavior-based DSL. The other side of this coin is that the proliferation of small example blocks introduces a performance overhead. Why? Because of RSpec test files’ lifecycle! I’ll describe broadly how RSpec handles a test file, and the performance implications. Then, we’ll see how we can make your RSpec tests run faster!
This is an intermediate-level post. If you’re unfamiliar with RSpec, start at the beginning of the series.
A typical test file
Let’s draw a quick books_controller_spec.rb that checks the behavior of a PATCH action.
  RSpec.describe BooksController do
    let(:book) do
      Book.create(title: "The Long Way to a Small, Angry Planet", blurb: nil)
    end
    describe "PATCH /books/:id" do
      let(:action) { patch :update, params: }
      let(:params) { { id: book.id, book: { blurb: } } }
      let(:blurb) do
        "Fleeing her old life, Rosemary Harper joins the multi-species crew of the Wayfarer as a file clerk, and follows them on their various missions throughout the galaxy."
      end
      it "returns an :ok response" do
        action
        expect(response).to have_http_status(:ok)
      end
      it "changes the blurb of the book" do
        action
        expect(book.reload).to eq(blurb)
      end
      it "redirects the user to the /show page" do
        action
        expect(response).to redirect_to("/books/#{book.id}")
      end
    end
  endThis test file is pretty straightforward:
- The setup creates a book with a title and no blurb.
- The tests verify that the BooksController#updatemethod works as intended: update the attributes, return the correct response, and check for final redirection.
One question, though. Can you tell how RSpec actually runs this test?
The lifecycle of a test file
Test files are separated into two concepts: example groups and examples.
Example groups are a recursive entity in RSpec. They represent:
- the logic within RSpec.describe BooksController do,
- the logic within describe "PATCH /books/:id" doorcontext "when something is different" do.
Examples represent the logic within it blocks. And it blocks encapsulate expectations.
RSpec.describe BooksController do                 # <-- top-level example group
    describe "PATCH /books/:id" do                # <-- nested example group
      it "returns an :ok response" do             # <-- example
        expect(response).to have_http_status(:ok) # <-- expectation
      end
    end
  endWhen running a test file, RSpec will:
- runthe top-level example group,
- within run,run_examples,
- if the examples are nested example groups, it will runrecursively,
- until RSpec can run an actual example, i.e. an itblock.
Next, for each example (it blocks), RSpec does a handful of things 1:
- run_before_context_hooks: creates the instance variables (your- let) and executes- beforeblocks,
- runs the expectations,
- generates the report,
- run_after_context_hooks: rollbacks the database and resets the ivars.
In layman’s terms, it means that for each it, RSpec runs anew all your let and before blocks, only to discard them all at the end of the process.
Let’s check our book instance in different it groups.
  it "returns an :ok response" do
    p book.id # => 1
  end
  it "changes the blurb of the book" do
    p book.id # => 1
  end
  it "redirects the user to the /show page" do
    p book.id # => 1
  endAt first, I would assume that my book is always the same book. And yet.
Let’s check the memory pointer associated with my book.
  it "returns an :ok response" do
    p book.object_id # => 56100
  end
  it "changes the blurb of the book" do
    p book.object_id # => 56220
  end
  it "redirects the user to the /show page" do
    p book.object_id # => 56350
  endUh, what?
What it tells us is that while each book looks identical at the database level (same id), each Ruby object we’re looking at is different (different memory pointer object_id).
So, RSpec recreates new Ruby objects for every it block.
But what about book always having the same id?
Most Rails integration of RSpec offers the ability to wrap examples in a database transaction 2, meaning that once your example has run, the database rolls back to its original state. Hence why you always have book with the same id.
The TL;DR is that the more it blocks you have, the more RSpec has to evaluate your setup, fill your test database with new records, instantiate new Ruby objects, and discard all of it.
While this decouples testing data from the order of test execution, for a lot of everyday tests, this is a tad overkill and slows your test suite down.
Ok, Rémi? So, how do we fix it?
Aggregate examples and cut setup time
There’s a very simple thing you can do, to cut setup time big time: aggregate your expectations in fewer examples.
  RSpec.describe BooksController do
    let(:book) do
      Book.create(title: "The Long Way to a Small, Angry Planet", blurb: nil)
    end
    describe "PATCH /books/:id" do
      let(:action) { patch :update, params: }
      let(:params) { { id: book.id, book: { blurb: } } }
      let(:blurb) do
        "Fleeing her old life, Rosemary Harper joins the multi-species crew of the Wayfarer as a file clerk, and follows them on their various missions throughout the galaxy."
      end
      it "successfully updates the book", :aggregate_failures do
        action
        expect(response).to have_http_status(:ok)
        expect(book.reload).to eq(blurb)
        expect(response).to redirect_to("/books/#{book.id}")
      end
    end
  endSure, you lose some legibility, but instead of having RSpec build your test setup three times, you only do it once. I’ll try and post an update with some benchmarks later on, but this seems quite a big gain especially when used across a whole test suite.
Note that I added the :aggregate_failures flag to my it block. This tells RSpec to not fail fast, to run all my expectations in the block, and to bundle all my failures together.
That’s it! Hope you liked this lengthy yack shaving, as much as I liked writing it!
Cheers,
Rémi - @remi@ruby.social
PS: I'm available for hire.
PSS: Many thanks to Sunny for sending me down this rabbit hole!