A Few RSpec Helpful Hints

Two main frameworks dominate the Ruby testing world: Rspec and MiniTest. RSpec is a very expressive testing framework with many great features and helpers to make tests readable. When writing RSpec tests, here are just a few not so obvious hints that could make tests even easier to write, read, and maintain.

Assuming a system exists with Books and Authors, let’s utilize these hints to make testing easy.

class Book
  attr_reader :title, :genre

  def initialize(title, genre)
    @title = title
    @genre = genre
  end
end

class Author
  attr_reader :books

  def initialize(name, books)
    @name = name
    @books = Array(books)
  end

  def has_written_a_book?
    !books.empty?
  end
end

subject and let Variables

A great way to keep specs DRY and readable are via subject and let variable declarations.

For example, if we want to assert an Author has a name, a test without let and subject variables might look something like:

describe Author do
  before do
    @book_genre = 'Historical Fiction'
    @book_title = 'A Tale of Two Cities'
    @book = Book.new(@book_genre, @book_title)
    @author_name = 'Charles Dickens'
    @author = Author.new(@author_name, [@book])
  end

  describe '#name'do
    it 'has a name set' do
      expect(@author.name).to eq(@author_name)
    end
  end
end

While correct, additional tests asserting number of books, a different name, or other things about this Author could become very verbose.

Instead, we can introduce subject and let variables to keep things DRY and reusable:

describe Author do
  let(:book_genre) { 'Historical Fiction' }
  let(:book_title) { 'A Tale of Two Cities' }
  let(:book) { Book.new(book_genre, book_title) }
  let(:book_array) { [book] }
  let(:author_name) { 'Charles Dickens' }
  subject { Author.new(author_name, book_array) }

  describe '#name'do
    it 'has a name set' do
      expect(subject.name).to eq(author_name)
    end
  end

  describe '#books' do
    context 'with books' do
      it 'has books set' do
        expect(subject.books).to eq(book_array)
      end
    end

    context 'without books' do
      context 'books variable is nil' do
        let(:book_array) { nil }

        it 'sets books to an empty array' do
          expect(subject.books).to eq([])
        end
      end

      context 'books variable is an empty array' do
        let(:book_array) { [] }

        it 'sets books to an empty array' do
          expect(subject.books).to eq([])
        end
      end
    end
  end
end

Instead of needing multiple before blocks to set and reset instance variables, this code utilizing let variables is concise and easy to read. More specifically, the way these tests work is: each time an it block is run, the let variables within the nearest context are used to initialize the subject.

By setting important let variables, a context that tests if subject.books is an array based on an input of nil or [] is as simple as changing the let definition: let(:book_array) { nil }.

Loose Expectations

When a test does not care about specifics, RSpec allows the use of general expectations and placeholders. These placeholders can help mitigate a test’s complexity by only focusing on what is truly important.

1. anything

Just like it sounds, the anything argument matcher can be used when a method requires an argument but the specifics do not matter for the test.

If an Author test wants to assert that a Book has been written, but doesn’t care about the title or genre of the book, anything can be used:

describe Author do
  describe #has_written_a_book?' do
    context 'when books are passed in' do
      subject { Author.new(name, books) }
      let(:books) { [Book.new(anything, anything)] }
      it 'is true' do
        expect(subject.has_written_a_book?).to eq(true)
      end
    end
  end
end

2. hash_including

When testing a method that expects a Hash, some elements of the Hash may be more important than others. The hash_including matcher allows a developer to assert one or many key/value pairs within a hash without needing to specify the entire hash.

Assuming the Book class has a method which instantiates a new HTTP client (for fetching additional information), it might look something like:

class Book
  # ...
  def fetch_information
    HTTPClient.new({ title: title, genre: genre, time: Time.now })
              .get('/information')
  end
end

The test for this method should assert that the client is initialized with a few crucial pieces. The hash_including argument matcher works nicely here.

describe Book do
  describe '#fetch_information' do
    let(:book_genre) { 'Historical Fiction' }
    let(:book_title) { 'A Tale of Two Cities' }
    subject { Book.new(title, genre) }

    it 'instantiates the client correctly' do
      expect(HTTPClient).to receive(:new)
                        .with(hash_including(title: book_title,
                                             genre: book_genre))
      subject.fetch_information
    end
  end
end

The versatile hash_including argument matcher can specify key/value pairs of an expected hash or just keys. Here, the test only care that the Book’s title and genre are passed along.

3. match_array

In Ruby, two Arrays are equal if and only if they contain the same elements in the same order. In some tests, this strict equality criteria might not always be necessary. For those cases, RSpec provides a match_array matcher to make tests less brittle.

If the Author class retrieved its list of books from a database, the order of books might not be consistent due to default scopes or when records are updated.

Given a fetch_books method that looks something like:

class Author
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def fetch_books
    BookDB.find_by(author_name: name)
  end
end

Utilizing match_array, a test can assert that the proper books are returned regardless of order:

describe Author do
  describe '#fetch_books' do
    let(:name) { 'Jane Austen' }
    let!(:books) do
      Array.new(2) do
        BookDB.create_book(author_name: name)
      end
    end

    subject { Author.new(name: name) }

    it 'fetches the books correctly' do
      expect(subject.fetch_books).to match_array(books)
    end
  end
end

Verifying Doubles

Mocking in RSpec is a simple way to ensure code that is expected to run, has indeed done so.

To fetch reviews for a Book, a third party API is used. The unit test surrounding that functionality should not actually call out to that API. Instead, a test should assert that the proper request is made.

class Book
  # ..
  def reviews
    Review::API.new(SUPER_SECRET_API_KEY)
               .get("reviews/?title=#{ title }&genre=#{ genre }")
  end
end

One way to test this code would be to write a stubbing method and return some test data:

describe Book do
  let(:book_genre) { 'Historical Fiction' }
  let(:book_title) { 'A Tale of Two Cities' }
  subject { Book.new(book_title, book_genre)  }

  describe '#reviews' do
    let(:fake_reviews) do
      [
        { critic: 'Pat M.', stars: 5, comments: 'Great Read!' },
        { critic: 'Sanjay R.', stars: 5, comments: 'Interesting!' },
        { critic: 'Rupa T.', stars: 4, comments: 'It was nice!' }
      ]
    end
    let(:test_api_client) { Review::API.new(TEST_API_KEY) }

    before do
      allow(Review::API).to receive(:new).and_return(test_api_client)
      allow(test_api_client).to receive(:get).and_return(fake_reviews)
    end

    it 'fetches them from the API' do
      expect(subject.reviews).to eq(fake_reviews)
    end
  end
end

This will work as long as the Review::API contract does not change how it fetches reviews. However, if the method does change for some reason, this code will pass and the application will fail in production.

Instead, an instance_double can be used to assert that a specific method is called while still returning test data:

describe Book do
  let(:book_genre) { 'Historical Fiction' }
  let(:book_title) { 'A Tale of Two Cities' }
  subject { Book.new(book_title, book_genre)  }

  describe '#reviews' do
    let(:fake_reviews) do
      [
        { critic: 'Pat M.', stars: 5, comments: 'Great Read!' },
        { critic: 'Sanjay R.', stars: 5, comments: 'Interesting!' },
        { critic: 'Rupa T.', stars: 4, comments: 'It was nice!' }
      ]
    end
    let(:test_api_client) do
      instance_double(Review::API, get: fake_reviews)
    end

    before do
      allow(Review::API).to receive(:new).and_return(test_api_client)
    end

    it 'fetches them from the API' do
      expect(subject.reviews).to eq(fake_reviews)
    end
  end
end

In one line, instance_double(Review::API, get: fake_reviews), the client class has been instantiated as an verifying double and the method get has been stubbed to return fake_reviews. The final important piece is: allow(Review::API).to receive(:new).and_return(test_api_client) which tells the class Review::API to use the double instead a new instance when calling new.

Now, if the Review::API instance method for fetching reviews changes the test will break, throwing a NoMethodError and hopefully saving a lot of debugging time.