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 .