Computer stuff, Ruby

Structuring RSpec files

RSpec is honestly the best testing framework I have ever worked with, and I dread the day I will to switch to a language that doesn't have a similar tool under its belt. However, RSpec and its documentation are very quiet when it comes to good practices. The docs are full to the brim with examples and details about the features of the framework, but not so much when it comes to how to use these features to build a good test suite.

I have worked with RSpec for multiple years now, and I have recently noticed that, while there are lots of documentation and blogs and Stack Overflow posts and whatnot out there, I rarely see people talk about how they organize their spec files. And in my opinion, the difference between a tidy spec and a messy one is gigantic: legibility, maintainability, collaboration... You name it. Every single aspect of an automated test suite can be improved if you take up some good habits regarding the shape of the tests.

So here you go. I this article, I will give a basic rundown of how I write a spec file, what is the logic behind it. There are also a bunch of miscellaneous tips and tricks to improve your day-to-day experience.

Requirements
I will not cover the basics of RSpec usage here. I will assume that you know most of what there is to know about "describe", "it", "let", and so on.
I tried to add as many links to the documentation as possible, in order to make sure everything is clear.

Folder structure

I use the folder structure that is recommended by RSpec themselves, which is a spec folder, that lives alongside the lib folder. In this folder lives the spec_helper.rb file. The spec files follow the same structure as the code files in the lib folder, each one with the _spec.rb suffix, which helps RSpec differentiate the spec files from fixture files, READMEs and other non-test files.

my-gem/
├─ .rspec
├─ lib/
│  ├─ my-gem.rb
│  └─ my_gem/
│     ├─ user.rb
│     ├─ api.rb
│     └─ api/
│         └─ client.rb
└─ spec/
   ├─ spec_helper.rb
   └─ my_gem/
      ├─ user_spec.rb
      └─ api/
         └─ client_spec.rb

File structure

Describe class / subject

Let's take a look at some_class_spec.rb. Imagine that I have not written the tests yet. If you're a TDD aficionado, you could say I have not even written the class yet.

The very first lines I write are almost exactly the same. Here is how they go.

# spec/my_gem/user_spec.rb

# frozen_string_literal: true

describe User do
  subject(:user) { described_class.new }
end

Using subject instead of let has two merits here:

  • It makes it crystal clear what the object under test is, no matter how convoluted the spec file gets;
    • Specifically, it is sure to stay at the top of the file. It must be the first thing anyone sees when they open the spec file.
  • It lets us use some of that oh-so satisfying RSpec syntactic sugar.

Also, naming your subject is always a good idea. When testing specific methods, it lets you show an example of what an actual usage might look like. In particular, try to name your subjects as the class itself when possible, just for consistency.

If the constructor has parameters, turn them into let and give them "nominal" values. You'll see later why in detail, but the gist of it that we want the specs to go from the most simple one first, to the most specific ones last, and sensible defaults let you set up nominal cases faster.

# frozen_string_literal: true

describe User do
  subject(:user) { described_class.new(first_name, last_name) }

  let(:first_name) { 'Richard' }
  let(:last_name) { 'Degenne' }
end
  • Keep your let in the same order as the order of the arguments.

Describing a method

Each method of your class must have its own describe block. Also, make sure to have to different method describe blocks in the same order as they are defined in the code. You know, for consistency.

# frozen_string_literal: true

describe User do
  subject(:user) { described_class.new(first_name, last_name) }

  let(:first_name) { 'Richard' }
  let(:last_name) { 'Degenne' }

  describe '#full_name' do
    subject(:full_name) { user.full_name }
  end
end

Again, I use subject to make clear what it is that I am currently testing, and to use RSpec's syntax to the max. Already, we can write a first test, thanks to the sensible defaults we defined earlier.

# frozen_string_literal: true

describe User do
  subject(:user) { described_class.new(first_name, last_name) }

  let(:first_name) { 'Richard' }
  let(:last_name) { 'Degenne' }

  describe '#full_name' do
    subject(:full_name) { user.full_name }

    it { is_expected.to eq 'Richard Degenne' }
  end
end

Pretty neat, if I do say so myself.

Adding contexts

Now that we have tested a nominal case, we can start digging into all the different possibilities. For instance, what happens if the User has no first name?

# frozen_string_literal: true

describe User do
  subject(:user) { described_class.new(first_name, last_name) }

  let(:first_name) { 'Richard' }
  let(:last_name) { 'Degenne' }

  describe '#full_name' do
    subject(:full_name) { user.full_name }

    it { is_expected.to eq 'Richard Degenne' }

    context 'without a first name' do
      let(:first_name) { nil }

      it { is_expected.to eq 'Mr/Mrs. Degenne' }
    end
  end
end

Generally, the first lines of a context should be a literal translation of the context name in RSpec terms. It makes it very clear what each let is doing and why it is here. Then, follow with any number of it you might need. Let's keep going.

# frozen_string_literal: true

describe User do
  subject(:user) { described_class.new(first_name, last_name) }

  let(:first_name) { 'Richard' }
  let(:last_name) { 'Degenne' }

  describe '#full_name' do
    subject(:full_name) { user.full_name }

    it { is_expected.to eq 'Richard Degenne' }

    context 'without a first name' do
      let(:first_name) { nil }

      it { is_expected.to eq 'Mr/Mrs. Degenne' }
    end

    context 'without a last name' do
      let(:last_name) { nil }

      it { is_expected.to eq 'Richard' }
    end

    context 'without a first name nor last name' do
      let(:first_name) { nil }
      let(:last_name) { nil }

      it { is_expected.to be_nil }
    end
  end
end

Nesting contexts

This last context is interesting. You might be tempted to nest it inside of "without a last name" or "without a first name", but I believe this is a mistake.

Nesting contexts might look like it's DRYing up you test, but it can have a couple adversary side effects:

  • It couples the setup for one test with the other, which means you might end up with extraneous, unwanted setup for the inner context;
  • It splits the setup in two parts If the outer context is a bit long, especially if it has a couple of tests or other nested contexts, that means that reading the setup for the inner context requires you to look at two different places, which makes the test less legible.

In my opinion, nesting contexts should be done very sparingly, only in cases where it makes perfect sense, and provided that the inner contexts are not so long or so complex as to jeopardize the legibility of the overall structure.

Keep going like this for all the methods in the User class, method after method, in the same order as the code file and you'll start to see some sort of repetitive logic here.

  1. describe Class
  2. subject
  3. Initialization lets
  4. describe method
    • subject
    • Method call lets
    • Nominal its
    • context
      • Setup lets
      • Specific its

Repeat until you've reached the end of the class. This methodical, almost mechanical approach to writing tests make it very easy to counter the blank page syndrome, and will ensure that all of your test files will look the same, read the same. Very satisfying, if you ask me.

Mocking

When writing unit tests, it's important to minimize the amount of interaction between the code under test and external factors, such as another class, or, God forbid, another service such as an API.

RSpec offers a bunch of stuff relative to mocking, stubbing and spying. And, just like in the previous section, I believe that the key to clean mocking is two-fold: organization and consistency.

Let's look at another method from our User class for example.

# frozen_string_literal: true

describe User do
  subject(:user) { described_class.new(first_name, last_name) }

  let(:first_name) { 'Richard' }
  let(:last_name) { 'Degenne' }

  # NOTE: I usually describe class methods above instance methods,
  #       since I usually define them that way in the code files.
  describe '.find' do
    subject(:find) { described_class.find(id) }

    let(:id) { 42 }

    let(:client) { instance_double(API::Client, find_user: user) }

    before do
      allow(API::Client).to receive(:new).and_return(client)
    end

   it { is_expected.to eq user }
  end

 # ...
end

So, a couple notes about this snippet.

  • Leave a blank line between the lets that are used for method calls and the lets that are used for mocks.
  • Always use instance_double when possible and use the shorthand response syntax. Use the longer allow(client).to receive syntax when necessary (when mocking errors, for instance)

There are two ways to write tests using mocks. Either you check that the mock has been called, or you check that the return value of your code is consistent with the mock. I would say that this question is merely a matter of Command-Query Separation (CQS).

If the call to the external service is a command (i.e. a call that performs an action), then check that the mock has been called. If the call is a query (i.e. a call that returns a value), then check the behavior of your code.

For instance, in the snippet above, the call to API::Client#find_user is clearly a query, so I set a sensible return value (a User) and use that in my test. If the implementation does not call the mock, then it cannot return the correct value, and the test will fail.

On the other hand, let's look at another method that uses a command.

# frozen_string_literal: true

describe User do
  subject(:user) { described_class.new(first_name, last_name) }

  let(:first_name) { 'Richard' }
  let(:last_name) { 'Degenne' }

  describe '#save' do
    subject(:save) { user.save }

    let(:client) { instance_double(API::Client, save: true) }

    before do
      allow(API::Client).to receive(:new).and_return(client)
    end

    it 'saves the user to the API' do
      save
      expect(client).to have_received(:save)
    end
  end

 # ...
end

Here, what is important is that the command was called. Note that we probably should also test that the mock was called with the correct arguments.

Here is an example of a context used to simulate a server-side error.

# frozen_string_literal: true

describe User do
  subject(:user) { described_class.new(first_name, last_name) }

  let(:first_name) { 'Richard' }
  let(:last_name) { 'Degenne' }

  describe '#save' do
    subject(:save) { user.save }

    let(:client) { instance_double(API::Client, save_user: true) }

    before do
      allow(API::Client).to receive(:new).and_return(client)
    end

    it 'saves the user to the API' do
      save
      expect(client).to have_received(:save)
    end

    context 'when the API fails' do
      before do
        allow(client).to receive(:save_user).and_raise(API::Error, 'Invalid user')
      end

      it 'fails with the same error' do
        expect { save }.to raise_error(API::Error, 'InvalidUser')
      end
    end
  end

 # ...
end

Just like in the previous section, the very first lines of the block are a mere transcription of the context's title into RSpec lingo, which make the whole thing very intuitive to read.

DRYing up

When it comes to factorizing stuff, such as mock setups and things like that, I try not to be too proactive. Don't get me wrong, factorization is good and necessary in a lot of places, but it also comes with consequences. If you are going to DRY up something, make sure that you are aware of these implications and that you are ready to accept them.

It can make the test harder to read. One of my key philosophies when it comes to tests is that closer is better. If two pieces of code are logically related to each other, then I try to keep them as close together as possible. There is nothing more frustrating than a snippet of code failing because of some random junk in a random file at the other end of the project. Factorizing tends to split related information across different places, so make sure that this process is clear and intuitive. Usually, the structure I've shown in the previous sections does the job, but keep an eye out for more complicated situations.

It can make the test harder to maintain. When DRYing up a piece of code that is shared between two or more branches, you make the hypothesis that these branches will always use the same, factorized code. If, later down the line, one of the branches diverges and needs a different, distinct code, then you will have two options: either split the dry code into two parts, or, add stuff to the dry bit, even though the other branches don't need it. This second option will lead to bloat, code paths that are unused in the other branches, but is also the easy way out, since it doesn't need refactoring a bunch of stuff.
The same can be said of tests, especially if you have a tendency to nest lots of contexts inside one another. Are all the setup and mocks of the outer contexts useful to the inner contexts? If two methods use the same mock, do you really need to set it up for all the methods of the class?

Conclusion

Automated test design is a topic that I like a lot, and I will probably write more about it. In particular, Ruby (and Rails) have lots of very neat tools and techniques that are worth checking out, but that will be for another day.

In the meantime, feel free to refer back to this article whenever you're looking to write your next spec file, or if you stumble onto a jumbo mess of a spec file left over by a coworker, and you want to clean it up. Whatever it is about RSpec, always remember.

Consistency is the key.

A question, a feedback? Write a comment!