A Few RSpec Helpful Hints
12 Jul 2017Two 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.