home articles hire me

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
  end

This test file is pretty straightforward:

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:

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
  end

When running a test file, RSpec will:

Next, for each example (it blocks), RSpec does a handful of things 1:

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
  end

At 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
  end

Uh, 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
  end

Sure, 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!

  1. You can check the code about run here

  2. The code about database migration is here