Let Your Tests Shine With RSpec

Paolo-_-Veñegas
Paolo Veñegas August 5, 2024

As a developer, I have a special place in my heart for tests. While they don't directly affect the functionality or performance of the software we produce, well-written tests allow us to make quick and confident strides, with the assurance of having safeguards against faulty assumptions and unexpected impact to other parts of the system.

Tests are also useful when diving into an unfamiliar codebase. They hint at how code is intended to be used, and which behaviour the original authors were confident to assert or considered important enough to spec—which ideally is all of it, but even partial coverage says something about priorities.

I believe code should be optimised for human readability, and I try to keep things concise and intentions clear in tests just as with functional code. In this article, I share some thoughts on how RSpec helps with that.

The overhead

First off, scenario data and configuration often make up the bulk of noise in tests. Without some grooming, you may soon find disjointed sets of hashes and JSON growing in your test suite.

Consider using fixtures or factories to manage test data as your codebase grows. These can then be combined with RSpec's shared contexts for maximum flexibility and reusability.

For setup that can't be factored out, let's see how we can it more readable.

Let it be

Let's review some RSpec fundamentals for starters.

To the uninitiated, let declarations might look like variable assignments with extra steps (especially if you're coming from Javascript), but they're smarter than that. let blocks are lazy-loaded, meaning they are computed at the point of invocation. This lets us build complex systems of dependencies that will re-evaluate with changes, while keeping things pretty readable.


RSpec.describe 'array compaction' do
  subject(:compacted_array) { array.compact }

  let(:array) { [x, y, z] }
  let(:x) { nil }
  let(:y) { nil }
  let(:z) { nil }

  context 'with only nil elements' do
    it 'is empty' do
      expect(compacted_array.empty?).to eq true
    end
  end

  context 'with a non-nil element' do
    let(:x) { 1 }

    it 'is not empty' do
      expect(compacted_array.empty?).to eq false
    end
  end
end

 

See how in the second example, the outcome is updated when we change a single variable. This is the declarative expressibility that makes RSpec a powerful tool.

Setting context

Once we're comfortable with how let works, we can see that subject does pretty much the same thing. The examples above would also work if compacted_array were declared with let. But by declaring it the subject, it is clearly the focus in this context.

Similarly, describe and context both group examples together and are functionally interchangeable, but RSpec offers both to make intentions clear for readers. We describe a specific thing or behaviour, and use context for variations in conditions. They are also infinitely nestable, so take advantage of that.

RSpec also lets us write one-liner expectations for the subject implicitly, which may help readability for trivial tests. The following examples are equivalent to the ones above.




context 'with only nil elements' do
    it { is_expected.to be_empty }
  end

  context 'with a non-nil element' do
    let(:x) { 1 }

    it { is_expected.not_to be_empty }
  end



 

Naming the subject is also optional, but unless you're only writing one-liners, you might as well call it something useful.

Magic matchers

Note that be_empty above is not a built-in matcher. When RSpec encounters an unrecognised matcher of the form be _ < predicate > it sends : < predicate > ? to the object in question, taking advantage of existing methods. There are variations of this metaprogrammed matching that make expectations read better too. For example:




expect(17).to be_an(Integer)               # calls 17.is_a?(Integer)

expect('a').to be_an_instance_of(String)   # calls 'a'.instance_of?(String)

expect(hash).to have_value(3)              # calls hash.has_value?(3)



 

Let for superusers

I find that tests are easier to follow when examples build on existing context, and changes between them are clear and granular. A nifty way to do this is by using super() to access and override the parent context to test progressively specific scenarios.



RSpec.describe SomeApiController do
  subject(:response) { get '/endpoint', {}, headers }

  let(:headers) { {} }

  context 'with no authorisation' do
    it 'returns an unauthorised response' do
      expect(response).to have_http_status(401)
    end
  end

  context 'with a valid token in the headers' do
    let(:headers) { super().merge('Authorization' => 'Bearer token') }

    it 'returns a success response' do
      expect(response).to have_http_status(200)
    end

    context 'when JSON is requested' do
      let(:headers) { super().merge('Content-Type' => 'application/json') }

      it 'returns the expected content' do
        # ...
      end

      context 'with the environment specified' do
        let(:headers) { super().merge('X-App-Env' => 'staging') }

        it 'returns specific results' do
          # ...
        end
      end
    end
  end
end


 

Incidentally, this hints at RSpec's implementation—a spec is essentially a hierarchy of objects, with letdeclarations turned into memoised methods inherited by example group objects down the tree.

Shared examples

RSpec also offers shared_examples for DRY parameterised testing. Say for example we have multiple APIs that share authorisation behaviour but use different endpoints and headers. We might factor out the authorisation tests into a shared example like so:




shared_examples 'an authorised API' do |endpoint, headers|
  # ...
end



 

While using parameters here is a common approach, it can be a stumbling block. Let's try to use the shared example in our API spec, passing the expected parameters.



RSpec.describe SomeApiController do
  let(:endpoint) { '/endpoint' }
  let(:headers) do
    # ...
  end

  it_behaves_like 'an authorised API', endpoint, headers
end




 

Running this gives us an error:




`endpoint` is not available on an example group (e.g. a `describe` or `context` block). It is
only available from within individual examples (e.g. `it` blocks) or from constructs that run
in the scope of an example (e.g. `before`, `let`, etc).



 

Since endpoint and headers were declared with let, they cannot be used here as they are only available within example scope. I frequently see tests fall back to vanilla variables at this point, which could mean duplicating definitions.

It may not be obvious, but the shared example actually inherits definitions from the invoking scope, so in this case, we can do away with parameters and access definitions directly.



RSpec.shared_examples 'an authorised API' do
  subject(:response) { get endpoint, {}, headers }

  context 'with no authorisation' do
    it 'returns an unauthorised response' do
      expect(response).to have_http_status(401)
    end
  end

  context 'with a valid token in the headers' do
    let(:headers) { super().merge('Authorization' => 'Bearer token') }

    it 'returns a success response' do
      expect(response).to have_http_status(200)
    end
  end
end





RSpec.describe SomeApiController do
  let(:endpoint) { '/endpoint' }
  let(:headers) do
    # ...
  end

  it_behaves_like 'an authorised API'

  # More specific tests here
end



 

The caveat of course is that naming needs to be consistent. If we want to use the shared example in a test that doesn't have those definitions or that has different naming, we can make definitions in a block passed to it_behaves_like:




RSpec.describe AnotherApiController do
  let(:target) { '/another_endpoint }

  it_behaves_like 'an authorised API' do
    let(:endpoint) { target }
    let(:headers) { {} }
  end
end



 

That's all for now, and thanks for reading. I hope this gives you some insight into RSpec and how it nudges us towards expressive testing.

As a final tip, I'd like to remind readers that consistency is key. On the road and in collaborative development, being predictable gets us where we're going. There are well-established conventions for Ruby and especially Rails, and RSpec likewise comes with best practices for the rich vocabulary it offers, which can also be enforced using tools like Rubocop.

Feel free to share your RSpec tips too, and happy testing!

 

Ps. if you have any questions

Ask here