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.