RSpec is a library for writing and running tests in Ruby applications. As its landing page states, RSpec is: "Behaviour Driven Development for Ruby. Making TDD productive and fun". We will return to that last part later.
This post, the first of a two-part series, will focus on introducing RSpec and exploring how RSpec especially helps with Behaviour Driven Development in Ruby.
Let's dive in!
The History of RSpec
RSpec started in 2005 and, through several iterations, reached version 3.0 in 2014. Version 3.12 has been available for almost a year. Its original author was Steven Baker, but the mantle has passed to several maintainers through the years: David Chelimsky, Myron Marston, Jon Rowe, and Penelope Phippen. Many contributors have also been part of the project.
When you read RSpec's good practices, you see this phrase early on: "focus on testing the behavior, not the implementation". That's not a strategy specific to RSpec; it's good advice for anyone writing tests.
In more practical terms, you should focus your tests on how the code you are testing behaves, not how it works.
For example, to test that a user's email address is valid, you should test that the validate_email
method returns false
when the email address is invalid. You are not testing a specific implementation but rather how that code reacts (i.e., behaves) when handling different strings that should be email addresses.
Behavior interests us: how the code acts and defines how our application will work. Furthermore, this approach simply lets us know whether things work (or not), and we can then be more relaxed to either fix or refactor the implementation. Our tests will tell us if the code's behavior has changed; we will know if our changes have been successful or not in the most direct way possible.
The inner workings of the code don't interest us so much. Of course, we don't want a bad implementation, but measuring a good implementation is much harder than knowing if the code does what it's expected to do or not.
Let's look at how to install RSpec next.
Installing RSpec for Ruby
RSpec comes as a gem, rspec-core
, so you can add it to a test group in your Gemfile. It's probably best you also add rspec
. It's a meta gem that includes rspec-core
, rspec-expectations
, and rspec-mocks
. You can also add the rspec-rails
one in a Ruby on Rails project.
Tests usually live in the spec/
folder at the root of your project, and you can launch them by providing a path to a file or a directory: rspec spec/models/*rb
, for example.
Now let's turn to how the RSpec DSL is set up to help with behavior testing.
RSpec DSL: How it Helps with Testing Behavior
RSpec's whole Domain Specific Language (DSL) is completely worked around behavior testing, giving you a direct way to describe the behavior you expect from your code within different contexts. A few parts could be smoother, but overall, tests in RSpec read directly as English, much like a good piece of Ruby code.
Tests in RSpec are not written as classes, with methods taking center stage as tests. Instead, tests are written as Ruby blocks (ever used do .. end
?), which, thanks to the method name we pass to the block as an argument, makes things very easy to read.
The primary method used in RSpec tests is describe
. describe
will contain one or more tests and can even contain more describe
calls. The second method is it
. The it
blocks are called examples and contain the actual assumptions; they are where the testing happens. Finally, RSpec relies on "expectations" within the it
blocks. Using the expect
method, we define how the subject of our test is expected to behave.
Now we can start writing some simple tests.
Simplest Tests in RSpec for Ruby: describe
and it
Let's imagine a User
class. We want a name
method that will output first and last names together if both are present (or just one, if one is missing). Those are the different behaviors we want to test.
Here is how the simplest of those contexts look expressed as an RSpec test.
# first call to describe, as topmost one, its description or title is used to tell what we are testing, here the User class.
# this title can be a string or a class name
describe User do
# we are adding a second describe to regroup the tests focused on the `name` method
describe '#name' do
# our first example ! Note the description focusing on the behavior
it 'returns the complete name' do
# we define a user variable by instantiating a user
user = User.new(first_name: 'John', last_name: 'Doe')
# and here comes the expectation
expect(user.name).to eq('John Doe')
end
end
end
Look at the general structure: we start by describing the focus of our test in the User
class, the method we are testing (name
), and then present an expected behavior.
Note how the expectation is written: we expect the value returned by user.name
to equal 'John Doe'
. The eq
method is a matcher. It allows us to match the tested value (on the left) and the expected one (on the right). The expect
part is always followed with to
or not_to
to dictate how the matcher that follows will be used.
Handling Multiple Contexts
While this first test shows us how it's done, it needs to catch up to what we want. It only handles one case if both first and last names are present. Let's see how we can test another case if the first name is absent.
describe User do
describe '#name' do
it 'returns the complete name when both first and last name are present' do
user = User.new(first_name: 'John', last_name: 'Doe')
expect(user.name).to eq('John Doe')
end
it 'returns only the last name when the first name is missing' do
user = User.new(last_name: 'Doe')
expect(user.name).to eq('Doe')
end
end
end
This looks more realistic, but we still need to test another case. The description is also relatively verbose and repetitive. We could add another layer of description between the describe '.name'
call and the example for each one. Thankfully, though, RSpec gives us a more obvious synonym for describe
to express what we need to express for different contexts: context
.
describe User do
describe '#name' do
context 'when both first and last name are present' do
it 'returns the complete name' do
user = User.new(first_name: 'John', last_name: 'Doe')
expect(user.name).to eq('John Doe')
end
end
context 'when the first name is missing' do
it 'returns only the last name' do
user = User.new(last_name: 'Doe')
expect(user.name).to eq('Doe')
end
end
end
end
Thanks to this, the whole file reads even more quickly and gives us, without much thinking, an understanding of exactly which behavior we are testing within different contexts.
Defining the Subject of Tests
We can use the subject
method to make the subject of our tests obvious.
describe User do
describe '#name' do
subject { user.name }
context 'when both first and last name are present' do
let(:user) { User.new(first_name: 'John', last_name: 'Doe') }
it 'returns the complete name' do
expect(subject).to eq('John Doe')
end
end
context 'when only the first name is present' do
let(:user) { User.new(first_name: 'John') }
it 'returns the complete name' do
expect(subject).to eq('John')
end
end
# ... other contexts
end
end
This is especially handy to avoid repetition and add clarity.
Handling Complex Setup (Before, After Hooks) in Ruby on Rails
In many cases, we need a bit more to prepare a context. Let's take, as an example, a class method on a Ruby on Rails model named latest_three
. It's expected to return the last three users created in the database. If we have less than that, we should get whatever users we have. By omitting the topmost describe
, here is how a test might look.
# note that we are using the '::method_name' here to refer to a class method, '#method_name' is reserved to refer to an instance method's name
describe '::latest_three' do
context 'when more than three users are present' do
it 'returns three users' do
3.times { User.create(first_name: Faker::Name.first_name, last_name: Faker::Name.last_name) }
expect(User.latest_three.size).to eq(3)
end
end
context 'when no users are present' to
it 'returns an empty collection' do
expect(User.latest_three.empty?).to be(true)
end
end
end
If you are unfamiliar with
Faker
, it's a Ruby library used to generate fake data such as names and dates through handy methods.
These two tests look ok, but the creation of the data doesn't belong to the example. It's important for the specific context, though: we need that data created before the example is run. To do so, we can use a before
block. Those blocks are run before the tests that follow them (in each block's context), thus giving us a perfect opportunity to set up our data.
describe '::latest_three' do
context 'when more than three users are present' do
before do
4.times { User.create(first_name: Faker::Name.first_name, last_name: Faker::Name.last_name) }
end
it 'returns three users' do
expect(User.latest_three.size).to eq(3)
end
end
context 'when no users are present' to
it 'returns an empty collection' do
expect(User.latest_three.empty?).to be(true)
end
end
end
Once again, I hope this shows how well thought-through the RSpec DSL is. Doesn't it read nicely and give us a good understanding of each context and the behavior we expect?
If we were tempted to destroy data or execute some other form of cleanup after a context, we could do so through an after
block. This is especially useful if you are writing tests using a database without the comfort of built-in automatic database cleanup between test runs.
Avoiding Repetition with let
in RSpec for Ruby
We still lack a few more concepts to be able to write real-world tests. Let's take the case of email validation again.
describe '#valid_email?' do
context 'when email does not contain an @' do
subject(:user) { User.new(email: 'bob') }
it { expect(user.valid_email?).to be(false) }
end
context 'when email does not have a tld' do
subject(:user) { User.new(email: 'bob@appsignal') }
it { expect(user.valid_email?).to be(false) }
end
context 'when email is valid' do
subject(:user) { User.new(email: 'bob@example.org') }
it { expect(user.valid_email?).to be(true) }
end
end
There are several repetitions, to the point of blurring the actual tests. We notice that the only thing that changes, in the setup of each context, is the actual value of the email address. Couldn't we use a variable to do this?
We can use let
blocks. They allow us to define a memoized helper method. The value is cached across multiple calls in the relative context. let
's syntax is similar to the one we saw for the subject
block: first, we pass a name for the helper, then a block to be evaluated. That block is lazy-evaluated. If we don't call it, it will never be evaluated.
describe '#valid_email?' do
subject(:user) { User.new(email: email) }
context 'when email does not contain an @' do
let(:email) { 'bob' }
it { expect(user.valid_email?).to be(false) }
end
context 'when email does not have a tld' do
let(:email) { 'bob@appsignal' }
it { expect(user.valid_email?).to be(false) }
end
context 'when email is valid' do
let(:email) { 'bob@example.org' }
it { expect(user.valid_email?).to be(true) }
end
end
Note that the subject
is moved up in the structure too: it will be evaluated within each context and thus use each context's email
value. Here we can see the purpose of subject
within a describe
with multiple contexts: we define the subject of the test early to make it obvious. We can then focus on expressing each different context we want to check the subject behavior in.
A Note on let
let
is lazily defined. In the above example, email
won't be instantiated and set until it's called. Once it is invoked, though, it's set. In effect, it's just like a memoized helper method.
Yet, in some cases, you might want to set the value associated with a let
before the examples run. To do so, you can use let!
. With let!
, the defined memoized helper method is called within an implicit before
hook for each example. In other words, the value associated with the let!
is eagerly defined before the example is run.
Let's create a user in our context before we run our example:
describe "#count_users" do
let(:account) { Account.create(name: 'Acc Ltd') }
let!(:user) { User.create(name: 'Jane', account: account) }
it "counts the users in the account" do
expect(account.count_users).to eq(1)
end
end
This prevents us from additional setup or even a call to user
within the before hook to get the value memoized.
let
Vs Instance Variables in RSpec for Ruby
Some developers might be tempted to rely on instance variables through a describe
or context
and their before
hooks:
describe "#count_users" do
before do
@account = Account.create(name: 'Acc Ltd')
@user = User.create(name: 'Jane', account: @account)
end
it "counts the users in the account" do
expect(@account.count_users).to eq(1)
end
end
This is not very practical. It adds dependencies and state sharing between contexts, weakens isolation, and is more difficult to debug.
The additional issue is that, if you were to make a call to an instance variable that has not been initialized, you'd get a nil
value in return. That's in contrast to the exception you'd get if you were to call a local variable that doesn't exist (raising a NameError
exception).
So, when writing tests with RSpec, let
is preferred, and let!
is to be used when you need an eager evaluation. Other methods to handle variables are not recommended.
Matchers in RSpec for Ruby
If describe
, context
, and it
are very important to the structure of RSpec tests, the key part to making actual tests is matchers.
We have only seen a few, mainly be()
and eq()
. Those two are the simplest ones and are very handy. Here is a list of the others you should know about as a start:
-
eq
: test the equality of two objects (actually, their equivalence, the same as==
);expect(1).to eq(1.0) # is true
-
eql
: test the equality of two objects (if they are identical, not just equivalent);expect(1).to eql(1.0) # is false
-
be
: test for object identity;be(true)
,be(false)
... -
be_nil
: test if an object isnil
-
be <= X
: test if a number is less or equal to a value (X); also works with<
,>
,>=
,==
-
be_instance_of
: test if an object is an instance of a specific class;expect(user.name).to be_instance_of(String)
-
include
: test if an object is part of a collection;expect(['a', 'b']).to include('a')
-
be_empty
: test if a collection is empty;expect([]).to be_empty
-
start_with
,end_with
: test if a string or array starts (or ends) with the expected elements;expect('Brian is in the kitchen').to start_with('Brian')
,expect([1, 2]).not_to start_with('0')
-
match
: test if a string matches a regular expression;expect(user.name).to match(/[a-zA-Z0-9]*/)
-
respond_to
: test if an object responds to a particular method;expect(user).to respond_to(:name)
-
have_attributes
: test if an object has a specific attribute;expect(user).to have_attributes(age: 42)
-
have_key
: test if a key is present within a hash;expect({ a: 1, b: 2 }).to have_key(:a)
-
raise_error
: test if a block of code raises an error;expect { user.name }.to raise_error(ArgumentError)
-
change
: test that a block of code changes the value of an object or one of its attributes;expect { User.create }.to change(User.count).by(1)
,expect { user.activate! }.to change(user, :is_active).from(false).to(true)
-
to_all
: test that all items in a collection match a given matcher;expect([nil, nil, nil]).to all(be_nil)
-
match_array
: test that one array has the same items as the expected one (the order isn't of importance);expect([1, 3, 2]).to match_array([2, 1, 3])
You can already write most of the tests you'll ever need with those matchers. To read more about matchers, you can check out RSpec's documentation.
A Few Thoughts
As you have seen, we have yet to write a line of actual code; we just wrote tests. That might be the most crucial point of this article: RSpec's DSL and structure allow you to write your test first from the behavior point of view. When you start to work on a new class, you can first express the behavior as an RSpec example within a given context. Then, simply rely on the guard rails to make your implementation a reality.
That's actually how TDD works. We are not writing tests just for the sake of tests. Instead, we write tests to express the behavior we want to see from the code. In effect, those tests are merely a transcription (through RSpec DSL) of the behavior expected for a feature.
Wrapping Up
To summarize what we have covered in this article:
- RSpec is a library that gives us a powerful DSL to express and test the behavior of code
-
describe
is the main element to structure tests in each file -
context
is equivalent todescribe
, but is used to separate different contexts for testing code behavior -
it
allows us to define examples: the blocks within which tests happen - expectations define the actual tests with matchers
-
let
andlet!
allow us to define memoized helpers through custom-named blocks to avoid repetitions;let!
is eagerly loaded -
subject
allows us to clearly define what is being tested and can be named
In the next post, we will look at specific types of tests for different parts of a Ruby on Rails application.
Until then, happy coding!
P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
Top comments (1)
Thanks for sharing @riboulet !