This post was created from a talk I gave at Boulder Ruby and was first published on the Brewer Digital Engineering Blog
RSpec Magic
RSpec is a popular Ruby testing framework that provides a lot of powerful features for you to poke and prod your apps to get them ready for production. Developers can learn the basic structure of tests fairly quickly but the magic-like syntax can be confusing, especially to early learners. So let’s dive into some of the building blocks of a spec.
The Domain-Specific Language (DSL) of RSpec feels magical at first. The syntax and structure creates very readable lines and blocks that are almost sentence like. This is in part from RSpec embracing Ruby’s implicit style. You can easily leave out lots of parenthesis and use lots of one-liners to create small, readable tests but they may not be clear what is happening.
require 'rspec'
require './lib/bank_account'
RSpec.describe BankAccount do
describe 'can be created with default balance' do
it { is_expected.to be_instance_of BankAccount }
it { is_expected.to have_attributes funds: 0 }
end
context 'with available funds' do
let(:account) { BankAccount.new(30) }
it 'can withdraw funds' do
expect { account.withdraw_funds(20) }.to change { account.funds }.from(30).to(10)
end
end
context 'with no available funds' do
let(:account) { BankAccount.new(0) }
it 'cannot withdraw funds' do
expect { account.withdraw_funds(30) }.not_to change { account.funds }
end
end
end
So let’s break down this test and look at what each block and method is doing. First by adding in parentheses and expanding the curly brace one-liners to do/end blocks, it become more clear what parts are methods, arguments, and blocks
require('rspec')
require('./lib/bank_account')
RSpec.describe(BankAccount) do
describe('can be created with default balance') do
it('is a bank account') do
is_expected.to(be_instance_of(BankAccount))
end
it('has zero default funds') do
is_expected.to(have_attributes({funds: 0 }))
end
end
context('with available funds') do
let(:account) { BankAccount.new(30) }
it('can withdraw funds') do
expect { account.withdraw_funds(20) }.to change { account.funds }.from(30).to(10)
end
end
context('with no available funds') do
let(:account) { BankAccount.new(0) }
it('cannot withdraw funds') do
expect { account.withdraw_funds(30) }.not_to change { account.funds }
end
end
end
The parentheses make it more clear what is an argument being passed to a method. So in line 4
RSpec.describe(BankAccount) shows us that RSpec is a class that we are calling the describe method on and we are passing it an argument of BankAccount, which is the Ruby class that we are testing.
Everything in the rest of the test is inside of the do and end for this code block, so all the rest of the code is being called within the scope of Rspec.describe.
Describe and Context
Now looking at line 5 we see the method describe being called. Then on line 14 and line 21 we see similar code blocks calling the method context. These are both methods given to us by RSpec (which we have access to since we are within the scope of RSpec from line 4). describe and context are aliases for the same method, they are interchangeable. They are both used to group related assertions together in logical chunks. This is a great example of RSpec embracing Ruby’s philosophy of giving developers several ways to do the same thing, allowing for developer choice. We could use only describe or only context and the tests will run the same way
require('rspec')
require('./lib/bank_account')
RSpec.context(BankAccount) do
context('can be created with default balance') do
it('is a bank account') do
is_expected.to(be_instance_of(BankAccount))
end
it('has zero default funds') do
is_expected.to(have_attributes({funds: 0 }))
end
end
context('with available funds') do
let(:account) { BankAccount.new(30) }
it('can withdraw funds') do
expect { account.withdraw_funds(20) }.to change { account.funds }.from(30).to(10)
end
end
context('with no available funds') do
let(:account) { BankAccount.new(0) }
it('cannot withdraw funds') do
expect { account.withdraw_funds(30) }.not_to change { account.funds }
end
end
end
require('rspec')
require('./lib/bank_account')
RSpec.describe(BankAccount) do
describe('can be created with default balance') do
it('is a bank account') do
is_expected.to(be_instance_of(BankAccount))
end
it('has zero default funds') do
is_expected.to(have_attributes({funds: 0 }))
end
end
describe('with available funds') do
let(:account) { BankAccount.new(30) }
it('can withdraw funds') do
expect { account.withdraw_funds(20) }.to change { account.funds }.from(30).to(10)
end
end
describe('with no available funds') do
let(:account) { BankAccount.new(0) }
it('cannot withdraw funds') do
expect { account.withdraw_funds(30) }.not_to change { account.funds }
end
end
end
So the choice is yours. In practice developers often use describe to describe a thing and use context to outline different scenarios, or provide context. So in line 4 we describe the class we are testing and then line 14 and line 21 we use context to outline two different scenarios that we expect to have different outcomes.
Note at one time there was one difference, context could not be used as a top level method only describe could, that is where we are calling RSpec.describe in line 4. Change log here. This is no longer the case in current versions of RSpec
Now about that argument. context and describe both take an argument. That argument can be a string used to name what you are testing, which RSpec will nicely print to standard output and help show you which tests passed or failed. That is the primary use for those strings, so name them however you want!
$ rspec rspec_examples.rb --format documentation
> BankAccount
can be created with default balance
is a bank account
has zero default funds
with available funds
can withdraw funds
with no available funds
cannot withdraw funds
Finished in 0.00493 seconds (files took 0.09074 seconds to load)
4 examples, 0 failures
describe and context can also take a class name instead of string as an argument. When passing in a class name, RSpec gives you a helper method called described_class which is just another way to call on an instance of the class itself.
IT
it blocks are where your testing usually happens. Just like describe, it takes an argument of a string that explains the test to the developer. it can take a block in which you include the assertions and the set up necessary for that test.
it('has zero default funds') do
is_expected.to(have_attributes({funds: 0 }))
end
Also like describe, you can pass a class to it but this does not override the top level class you are testing. For example this it block when given class Integer will fail, because described_class is still BankAccount
RSpec.describe(BankAccount) do
describe 'describe_class does not change' do
it(Integer) do
expect(described_class).to(be_instance_of(BankAccount))
end
end
end
$ rspec rspec_examples.rb --format documentation
BankAccount
describe_class does not change
Integer (FAILED - 1)
Failures:
1) BankAccount describe_class does not change Integer
Failure/Error: expect(described_class).to(be_instance_of(BankAccount))
expected BankAccount to be an instance of BankAccount
# ./rspec_examples.rb:7:in `block (3 levels) in <top (required)>'
Finished in 0.0157 seconds (files took 0.12 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./rspec_examples.rb:6 # BankAccount describe_class does not change Integer
it blocks can also be written out as one-liners. You can leave out the argument string describing the test and instead RSpec will print out the assertion itself as the name. The one line syntax relies on either defining a subject or passing a class to your example group, which is then implicitly defined as subject. So in our main example, there is an implicit definition of an instance of BankAccount that is used in the one liners on line 7 and line 10.
Assertions
When writing tests, assertions are the methods that are checking the actual results of your code against the expected result. Typically they follow the pattern of expect(result).to equal(expected_result). RSpec is matching the result to the expected_result. The equal is a matcher and RSpec has lots of them, many of them aliases of other ones.
(Table to be formatted)
Matcher Alias a_truthy_value be_truthy a_nil_value be_nil to_not not_to an_instance_of be_a equal_to eq
So in line 10, we see that RSpec is checking that the value result of calling new_bank_account.funds equals 0.
Before
before is another RSpec method that we can use to help setup data for our tests. It does what it sounds like and runs before the describe or context blocks. It can be used to set up data that you need to use for all your tests. It takes an argument of :each or :all.
RSpec.describe(BankAccount) do
before(:each) do
@bank_account = BankAccount.new
end
describe 'can be created with default balance' do
it('is a bank account') do
expect(@bank_account).to(be_instance_of(BankAccount))
end
it('has zero default funds') do
expect(@bank_account).to(have_attributes({funds: 0 }))
end
end
end
before(:each) does what it sounds like, it runs before each it block.
So in this example the before block will run twice.
This means there is no shared data between blocks, as the data is made fresh for each example, but also can slow down your tests if there is unnecessary code being run.
RSpec.describe(BankAccount) do
before(:all) do
@bank_account = BankAccount.new
end
describe 'can be created with default balance' do
it('is a bank account') do
expect(@bank_account).to(be_instance_of(BankAccount))
end
it('has zero default funds') do
expect(@bank_account).to(have_attributes({funds: 0 }))
end
end
end
before(:all) will run once at the start and the data will persist between tests, so any changes you make in one test will affect later tests.
If you modify attributes of @bank_account in one it statement, they will be remain changed for the next it statement
Let
The more performant solution would be to use let. let defines a method whose return value is memoized after the first time it is called. let doesn’t run that method until the variable is called for the first time, also known as lazy-evaluation. So the new BankAccount from line 19 is not created until it is invoked inside the it block on line 21. Similar to before each, the data does not persist. So each new it block will start fresh.
To break this block down. let(:account) assigns the variable account, then the block defines the code that will run the first time the variable is called, in this case creating a new BankAccount instance.
Once you call account on line 21, the BankAccount.new method runs and the result, a BankAccount instance, is assigned to the variable account.
let! will force the method to run when it is evaluated, bypassing the lazy-loading. This is generally frowned upon due to the performance implications and added confusion to the developer reading the test.
Takeaways
- An Rspec spec is built out of
describeandcontextblocks which haveitblocks within them. ContextandDescribeare the aliases for the same method, choosing one of the over is a style preference.itblocks are where your assertions live, and they are inside of yourdescribeandcontextblocksitblocks are used to group related assertions and set up- Rspec matchers are used to test the expected outcome to the actual outcome of your code. There are tons of assertions, many of them have aliases.
- The strings passed to
context,describe, anditare used to tell the developer what the test is for. These strings are printed to the terminal when running your specs to tell you which tests passed and failed. - Before hooks run prior to
itblocks and are used to set up data that is needed for all your tests. Be careful not to create unnecessary data! - The difference between
letandlet!is thatletis lazy-evaluated, it does not run until it is called the first time where aslet!runs right away.