6 min read

Advanced concepts in RSpec

RSpec offers robust features including contexts, let, let!, and before/after hooks for efficient and maintainable test code. These features help to create more organized, readable, and reliable tests. Let's explore how.
Advanced concepts in RSpec
Photo by Angel Santos / Unsplash

As we have seen in the previous part of this RSpec series, RSpec's syntax is intuitive and easy to use. Yet, there are advanced concepts that can significantly enhance the power and flexibility of your tests.
In this article, we'll explore some of these concepts, including context, hooks, and regular matchers. By mastering these advanced features, you'll be able to write more effective and efficient tests that can handle complex scenarios with ease. So let's dive in and discover how to take your RSpec skills to the next level!

Contexts

As we noted in the previous post, RSpec tests are written with a custom DSL:

we describe a subject of the test within a given context and then provide examples within which we use matchers to compare an actual value with the expected one.

This concept of a "given context" is not just a piece of vocabulary, it's definitely part of the DSL and should be part of your everyday use of RSpec when writing tests.

We saw such a usage in the previous part too, here is an extended version of the example we used last time to showcase better the use of context.

describe User do
  describe '#name' do
    context 'when the user has a first name and last name' do
      subject { User.new(first_name: 'John', last_name: 'Smith') }

      it { expect(subject.name).to eq('John Smith') }
    end

    context 'when the user has only a last name' do
      subject { User.new(last_name: 'Smith') }

      it { expect(subject.name).to eq('Smith') }
    end
  end
end

While in the details, describe and context are the same thing; they serve a different purpose for the developer who is writing and reading the code.

With those two examples, we can see that we have two different contexts within which the code is being run. We also see the different expectations of outcomes in each context.

Without such a layer of abstraction, we would have stuffed the context as a setup of each expectation within the example itself as a sequential list of commands to run.

describe User do
  describe '#name' do
    it 'compose the name from first and last name' do
      user = User.new(first_name: 'John', last_name: 'Smith')
      expect(user.name).to eq('John Smith')
    end

    it 'compose the name with just the last name if the first name is missing' do
      user = User.new(last_name: 'Smith')
      expect(user.name).to eq('Smith')
    end
  end
end

While it appears more concise (in the number of lines) it certainly isn't in the descriptions' length. With the lack of nesting, we also lose the clear separation of the two different contexts.

It's, of course, a matter of taste. Yet, most Ruby developpers prefer the former version relying on contexts rather than the one without. See BetterSpecs.org : https://www.betterspecs.org/#contexts

let and let!

The above example can still be improved by using let. let is used to initalize an action that is lazy loaded to test specs.

describe User do
  let(:first_name) { 'John' }
  let(:last_name)  { 'Smith' }

  describe '#name' do
    context 'when the user has a first name and last name' do
      subject { User.new(first_name: first_name, last_name: last_name) }

      it { expect(subject.name).to eq("#{first_name} #{last_name}") }
    end

    context 'when the user has only a last name' do
      subject { User.new(last_name: last_name) }

      it { expect(subject.name).to eq(last_name) }
    end
  end
end
Curious to know what let does exactly? See this StackOverflow: https://stackoverflow.com/questions/5359558/when-to-use-rspec-let/5359979#5359979

let! allows us to do the same thing but without the laziness. The code associated with the variable will be run during the preparation of the test rather than when the variable is first called. This is handy for getting some resources created within a database before the actual test is run (for example).

describe User do
  let!(:user) { User.create(name: 'bob') }
  let!(:admin) { User.create(name: 'jane', role: 'admin') }

  describe '#is_admin?' do
    context 'when the user is an admin' do
      subject { user }

      it { expect(subject.is_admin?).to be(true) }
    end

    context 'when the user is not an admin' do
      subject { admin }

      it { expect(subject.is_admin?).to be(false) }
    end
  end

  describe '#is_regular?' do
    context 'when the user is an admin' do
      subject { admin }

      it { expect(subject.is_regular?).to be(false) }
    end

    context 'when the user is not an admin' do
      subject { user }

      it { expect(subject.is_regular?).to be(true) }
    end
  end
end

Here we are using let! to create, before the tests, two users: a regular user and an admin. We use those to test two methods: is_admin? and is_regular?.

One should note that this is an example. In a real use case we would avoid to call upon the database (through the use of User.create, relying instead on User.new thus avoiding the database) to test such methods.

Hooks

Another practical pair of features are the before and after hooks. They provide a way to run code before or after every example or group of examples within an RSpec test suite. These hooks can be used to set up preconditions, such as initializing objects or databases or to perform cleanup tasks, such as deleting temporary files or closing connections. Understanding how to use before and after hooks effectively can significantly enhance the reliability and maintainability of your tests.

before

A classic use case of the before hook is to create a set of data before running a test. Here is a use case: we want to write a scope in the User model that will only return users with the role of 'admin'.

describe User do
  describe '.admins' do
    let!(:admin_user) { User.create(name: 'bob', role: 'admin') }
    before { User.create(name: 'jane') }

    it { expect(User.admins).to eq([admin_user]) }
  end
end

In this case, we chose not to care about the non-admin user. That use is considered "noise": we want to have something else in the database, but we don't need to have a variable attached to it.

More on matchers

RSpec comes with a lot of matchers by default. If you add the ones provided in the rspec-rails gem you have pretty much everything you need. There is a possibility to add custom ones, but let's first see a handful of the standard ones you need to know about.

  • testing identity: be()
    expect(actual).to be(expected)
  • testing equivalence: eq()
    expect(actual).to eq(expected)
  • comparing with <, > , <=, and >=
    expect(actual).to be <= expected
  • comparing with be_between with either inclusive, exclusive as complement
    expect(actual).to be_between(minimum, maximum).inclusive
  • matching a regular expression with match
    expect(actual).to match(/expression/)
  • comparing within a delta of the expected value with be_within(delta)
    expect(actual).to be_within(0.1).of(expected)
  • checking that a string starts or ends with a specific string with start_with and end_with
    expect(actual).to start_with('this')
  • testing that an object is of specific class with be_instance_of, be_kind_of
    expect(actual).to be_instance_of(String)
  • testing that a method or attribute exists for an object or class with respond_to
    expect(actual).to respond_to(:method_name)
  • testing that a variable exist with exist
    expect(actual).to exist
  • testing truthiness with be_truthy (neither nil nor false) and be_falsey (nil or false)
    expect(actual).to be_truthy
  • testing true/false with be(true) and be(false)
    expect(actual).to be(true)
  • testing for nilness with be_nil or be(nil)
    expect(actual).to be_nil
  • testing for emptiness (of an array or collection) with be_empty
    expect(actual).to be_empty
  • testing if a hash has a specific key with have_key(:a)
    expect({ a: 1 }).to have_key(:a)
  • testing for inclusion in a string, array, or collection with include
    expect([1, 2, 3]).to include(1)
  • testing for an exact match between two arrays with match_array
    expect([1, 2]).to match_array([1, 2])
  • expecting errors with raise_error
    expect { object.method_with_raise }.to raise_error(OptionalErrorClass)
  • testing for change with change
    expect { User.create(name: 'bob') }.to change { User.count }.by(1)

There is even more to this, and you can check the official RSpec documentation on relishapp.com : https://relishapp.com/rspec/rspec-expectations/docs/built-in-matchers.

Wrapping up

Understanding and mastering the concepts of contexts, let & let!, hooks, and base matchers are essential for writing effective and reliable tests using RSpec. These features help us create organized and readable tests while keeping our test code DRY and duplication-free.

By using contexts, we can structure our tests according to the different scenarios they cover.

let and let! allow us to define variables and objects used across multiple tests while ensuring they are correctly initialized.

Hooks provide us with a way to execute code before and after our tests, helping us manage setup and teardown of resources.
Finally, the base matchers are the building blocks of our assertions, allowing us to test for equality, truthiness, inclusion, and more.

Now that we have covered those, we can move on to more advanced topics such as shared examples, custom matchers, and testing external dependencies. By improving our knowledge and skills with RSpec, we can write tests that accurately reflect the behavior of our code and provide confidence in our software.

Ready to go further?

If you want to dive deeper into RSpec and take your testing skills to the next level, consider checking out our comprehensive eBook, "Learn RSpec: mastering tests for Ruby applications". This guide will provide you with all the tools and knowledge you need to write effective tests using RSpec confidently. Whether a beginner or an experienced developer, our eBook will walk you through creating and running tests with RSpec from start to finish. You'll learn to use contexts, let and let!, hooks, and base matchers to write clean and maintainable tests.

If you're interested in taking your RSpec skills even further, consider joining our mentoring program or signing up for our workshop and training program for your team. Our experienced mentors will work with you one-on-one to help you solve specific testing challenges and provide guidance on best practices. Our workshop and training program will provide your team with the tools and knowledge they need to write effective tests using RSpec, so you can ensure the quality and reliability of your codebase. Don't hesitate to contact us to learn more about our services.