home articles newsletter

Introduction to RSpec: the syntax

In this second part of the introduction to RSpec, I want to explore the methods you’ll use the most when testing with RSpec: describe, subject, let and let!, context, and it.

If you haven’t read the first part - how to setup RSpec and name files and, digging into their basic structure, go and read it, I’ll wait.

Done? Let’s move on to our second installment!

We’ll build an example test for a User model. I’ll explain key concepts and keywords along the way.

Describe your abstraction with describe

We’ve already dug into the inner workings of describe in the first post. So here’s a summary of what RSpec describe is:

describe is a method used to group your tests around a common abstraction: a class, a request, etc. It’s a wrapper that builds an example group.

Since methods are an abstraction, you usually nest several describes in your example group. Let’s test an instance method - #full_name.

  # spec/models/user_spec.rb

  require 'rails_helper'

  RSpec.describe User, type: :model do
    describe '#full_name' do
      # test the output of the method full_name
    end
  end

As you can see, the first describe is called on the module RSpec and describes the User class. This is the top-level example group. The second describe is called within our top-level example group and describes the method #full_name.

The second describe is nested in the top-level one. Both describe something: a class, a method.

The emphasis on “something” here is important. We’ll get back to it when we talk about context.

Note how the second describe takes a string as an argument. The string is the description of the abstraction your testing. As a rule of thumb, when testing a method, the description is its name. Prefix it with a hash or a dot, based on the scope of the method:

When you run your tests, RSpec will output your description like this:

  Randomized with seed 4321

  User
    #full_name

Once we write tests, RSpec will output more details.

Declare your abstraction with subject (and named subject)

subject represents the abstraction you’re testing. Your subject can be a method, a request, a serialized object, etc. In our example, it’s the method #full_name.

  # spec/models/user_spec.rb

  require 'rails_helper'

  RSpec.describe User, type: :model do
    describe '#full_name' do
      subject { user.full_name }
    end
  end

subject is a method that takes a block in which I call the method #full_name on an instance of User.

What’s the difference between subject and describe then? subject tells RSpec what to evaluate. describe is just here to make your tests readable.

Sometimes, you need to explicitly reference your subject in your tests (we’ll see an example in a little while). In that case, it’s best to name your subject.

  # spec/models/user_spec.rb

  require 'rails_helper'

  RSpec.describe User, type: :model do
    describe '#full_name' do
      subject(:full_name_method) { user.full_name }
    end
  end

Here full_name_method will reference the output of the #full_name method in our tests. I’ve chosen a somewhat crappy name - full_name_method - to highlight the difference between the name of the subject and the actual evaluation of the method.

Why the User class is not the subject of our whole example group? Are we not testing the model User? You’re right. We are testing the User class but you can’t test the class as a whole. So you test each part, each behavior until they sum up to your class. Each behavior is a different test subject. This is why RSpec advertises itself as behavior driven development.

But hang on! In our example, where does that user.full_name comes from? Let me show you how variables work in RSpec.

Creating variables with let and let!

let is specific to RSpec. It’s the method that lets you create variables.

  # spec/models/user_spec.rb

  require 'rails_helper'

  RSpec.describe User, type: :model do
    let(:user) { User.create(first_name: 'Buffy', last_name: 'Summers') }

    describe '#full_name' do
      subject { user.full_name }
    end
  end

Here, we create a user and assign it to the variable user.

But why don’t we just use a good old user = User.create? We could get rid of those : and {}!

Technically, you could. This is valid and your tests will be able to access the value store in user.

But let is not just a way to assign values to variables.

First, let is lazy-evaluated. It means - in our example - that the user won’t be created and assigned until your tests invoke the variable user for the first time. Second, let is a memoized helper method. Once the value is evaluated for the first time, it won’t be evaluated again. Its value is cached across the example group. In our example above, user is evaluated once and its value is cached across all examples because let(:user) is defined at the top-level example group (the RSpec.define block)1.

What about let!?

If you want to force the evaluation of User.create before your tests run, use let! instead. This is useful when you need to create several instances as a context for your tests.

To recap:

Other variables

In your test, you can also access the variable described_class which represents the core abstraction of your spec file. In our case, this is the User class.

Contextual let

You often need to change the value of variables based on some context. Here’s an example:

  # spec/models/user_spec.rb

  require 'rails_helper'

  RSpec.describe User, type: :model do
    let(:user) { User.create(first_name: first_name, last_name: last_name) }

    describe '#full_name' do
      subject { user.full_name }

      let(:first_name) { 'Buffy' }
      let(:last_name) { 'Summers' }
    end
  end

Here, user is still accessible everywhere, but first_name: 'Buffy' and last_name: 'Summers' are only accessible within the context of the #full_name example group.

This allows you to change the value when you need it. Speaking of context, let’s check it out.

Test conditionnal behavior with context

context is best understood with an example. Let me show you something before I give you a definition.

  # spec/models/user_spec.rb

  require 'rails_helper'

  RSpec.describe User, type: :model do
    let(:user) { User.create(first_name: 'Buffy', last_name: 'Summers') }

    describe '#full_name' do
      subject { user.full_name }

      context 'when a user has a first_name and a last_name' do
        # test the output of the method full_name under a specific constraint
      end

      context 'when a user has no last_name' do
        # test the output of the method full_name under another constraint
      end
    end
  end

Can you guess what context does?

context is used to group your tests around - drum roll 🥁 - a specific context.

I know. It sounds silly when you spell it out.

If you expect a different output for your method #full_name based on a condition - a blank last name, for example - you can group your tests in several context blocks.

Some examples of contexts you’ll use in your tests:

Here’s the output of your tests with some contexts:

  Randomized with seed 4321

  User
    #full_name
      when a user has a first_name and a last_name
        ...
      when a user has no last_name
        ...

When to use describe and when to use context

To be honest, when I first started testing, I would use describe and context indifferently. Then after a while, I set into a pattern:

Describe your test with it

We’re getting there! But before we write the actual content of our first test, we need to describe it first. And this is what it does. As describe is a wrapper to build an example group, it is a wrapper to build an example. ittakes a string as an argument.

The description of each test should state the expected behavior of the abstraction you’re testing.

  # spec/models/user_spec.rb

  require 'rails_helper'

  RSpec.describe User, type: :model do
    let(:user) { User.create(first_name: 'Buffy', last_name: 'Summers') }

    describe '#full_name' do
      subject { user.full_name }

      context 'when a user has a first_name and a last_name' do
        it "returns the user's full name" do
          # test expected behaviour
        end
      end

      context 'when a user has no last_name' do
        it "returns an error" do
          # test the expected behaviour
        end
      end
    end
  end

For each context, I expect a specific output. it makes it clear what I should get out of my abstraction.

RSpec will print out your its in your console.

  Randomized with seed 4321

  User
    #full_name
      when a user has a first_name and a last_name
        returns the user's full name
      when a user has no last_name
        returns an error

See? This is super easy to read. You are testing the instance method full_name defined in your class User. When your instance of User has a value for first name and last name, you expect your method to return your user’s full name. When your user has no last name, you expect your method to raise an error.

And now, it’s time to write the body of our first test, our expectation.

What did you expect?

RSpec is behavior-based. It allows you to compare the expected behavior of your abstraction with the actual behavior of your abstraction (i.e. your subject).

You expect your test’s subject to equal / contain / include your expected output. Let me show you.

  # spec/models/user_spec.rb

  require 'rails_helper'

  RSpec.describe User, type: :model do
    let(:user) { User.create(first_name: 'Buffy', last_name: 'Summers') }

    describe '#full_name' do
      subject { user.full_name }

      context 'when a user has a first_name and a last_name' do
        it "returns the user's full name" do
          expect(subject).to eq("Buffy Summers")
        end
      end

      context 'when a user has no last_name' do
        it "returns an error" do
          expect(subject).to raise_error(UserErrors::InvalidLastName)
        end
      end
    end
  end

expect takes your subject as an argument and returns an ExpectationTarget object with the actual result of your abstraction evaluation stored in it. Then expect calls the method .to with a matcher as an argument. In our example, eq and raise_error are matchers. These matchers take an argument too: the expected output.

The matchers’ job is to compare the expected output with the actual output. Here’s the code from RSpec, it’s pretty straighforward:

  module RSpec
    module Matchers
      module BuiltIn
        class Eq < BaseMatcher
          # ...

          private

          def match(expected, actual)
            actual == expected
          end
        end
      end
    end
  end

If your expectation is fulfilled, your example’s description will print out green. If the expectation is not fulfilled, it’ll output red.

Before we conclude, let list the basic expectations and matchers from RSpec.

Basic expectations and matchers

Expectations’ job is mostly to create an ExpectationTarget object that responds to .to or .to_not. .to and .to_not allow you to create positive or negative expectations. This is also where RSpec handle specific examples, like aggregate failures (i.e. when you test several assertions in one example2).

You can write your expectation in three ways:

  # spec/models/user_spec.rb

  require 'rails_helper'

  RSpec.describe User, type: :model do
    let(:user) { User.create(first_name: 'Buffy', last_name: 'Summers') }

    describe '#full_name' do
      subject { user.full_name }

      it "returns the user's full name" do
        expect(subject).to eq("Buffy Summers")
      end

      it { expect(subject).to eq("Buffy Summers") }

      it { is_expected.to eq("Buffy Summers") }
    end
  end

RSpec generates its own message when your write it { expect(subject).to ... }. is_expected triggers the evaluation of your subject implicitly and returns an RSpec-generated message.

You can dive into expectations in the RSpec codebase.

Matchers are a powerful feature. They allow you to define specific matching rules between your actual output and your expected output:

You can discover all matchers here.

TL;DR

You were too lazy to read all this? I’ve made you a gif.

a gif of a test building up

If you feel like digging deeper, here are a few links for you:

Cheers,

Rémi - @remi@ruby.social

  1. This is the context which encapsulates all your tests for the class User

  2. aggregate failures is pretty useful for testing attributes in a serialized object.