Advanced concepts in RSpec
As we have seen in the previous part of this RSpec series, RSpec's syntax is intuitive and easy to use. Yet, there are advanced concepts that can significantly enhance the power and flexibility of your tests.
In this article, we'll explore some of these concepts, including context, hooks, and regular matchers. By mastering these advanced features, you'll be able to write more effective and efficient tests that can handle complex scenarios with ease. So let's dive in and discover how to take your RSpec skills to the next level!
Contexts
As we noted in the previous post, RSpec tests are written with a custom DSL:
we describe a subject of the test within a given context and then provide examples within which we use matchers to compare an actual value with the expected one.
This concept of a "given context" is not just a piece of vocabulary, it's definitely part of the DSL and should be part of your everyday use of RSpec when writing tests.
We saw such a usage in the previous part too, here is an extended version of the example we used last time to showcase better the use of context
.
describe User do
describe '#name' do
context 'when the user has a first name and last name' do
subject { User.new(first_name: 'John', last_name: 'Smith') }
it { expect(subject.name).to eq('John Smith') }
end
context 'when the user has only a last name' do
subject { User.new(last_name: 'Smith') }
it { expect(subject.name).to eq('Smith') }
end
end
end
While in the details, describe
and context
are the same thing; they serve a different purpose for the developer who is writing and reading the code.
With those two examples, we can see that we have two different contexts within which the code is being run. We also see the different expectations of outcomes in each context.
Without such a layer of abstraction, we would have stuffed the context as a setup of each expectation within the example itself as a sequential list of commands to run.
describe User do
describe '#name' do
it 'compose the name from first and last name' do
user = User.new(first_name: 'John', last_name: 'Smith')
expect(user.name).to eq('John Smith')
end
it 'compose the name with just the last name if the first name is missing' do
user = User.new(last_name: 'Smith')
expect(user.name).to eq('Smith')
end
end
end
While it appears more concise (in the number of lines) it certainly isn't in the descriptions' length. With the lack of nesting, we also lose the clear separation of the two different contexts.
It's, of course, a matter of taste. Yet, most Ruby developpers prefer the former version relying on contexts rather than the one without. See BetterSpecs.org : https://www.betterspecs.org/#contexts
let and let!
The above example can still be improved by using let
. let
is used to initalize an action that is lazy loaded to test specs.
describe User do
let(:first_name) { 'John' }
let(:last_name) { 'Smith' }
describe '#name' do
context 'when the user has a first name and last name' do
subject { User.new(first_name: first_name, last_name: last_name) }
it { expect(subject.name).to eq("#{first_name} #{last_name}") }
end
context 'when the user has only a last name' do
subject { User.new(last_name: last_name) }
it { expect(subject.name).to eq(last_name) }
end
end
end
Curious to know what let
does exactly? See this StackOverflow: https://stackoverflow.com/questions/5359558/when-to-use-rspec-let/5359979#5359979
let!
allows us to do the same thing but without the laziness. The code associated with the variable will be run during the preparation of the test rather than when the variable is first called. This is handy for getting some resources created within a database before the actual test is run (for example).
describe User do
let!(:user) { User.create(name: 'bob') }
let!(:admin) { User.create(name: 'jane', role: 'admin') }
describe '#is_admin?' do
context 'when the user is an admin' do
subject { user }
it { expect(subject.is_admin?).to be(true) }
end
context 'when the user is not an admin' do
subject { admin }
it { expect(subject.is_admin?).to be(false) }
end
end
describe '#is_regular?' do
context 'when the user is an admin' do
subject { admin }
it { expect(subject.is_regular?).to be(false) }
end
context 'when the user is not an admin' do
subject { user }
it { expect(subject.is_regular?).to be(true) }
end
end
end
Here we are using let!
to create, before the tests, two users: a regular user and an admin. We use those to test two methods: is_admin?
and is_regular?
.
One should note that this is an example. In a real use case we would avoid to call upon the database (through the use of User.create, relying instead on User.new thus avoiding the database) to test such methods.
Hooks
Another practical pair of features are the before
and after
hooks. They provide a way to run code before or after every example or group of examples within an RSpec test suite. These hooks can be used to set up preconditions, such as initializing objects or databases or to perform cleanup tasks, such as deleting temporary files or closing connections. Understanding how to use before
and after
hooks effectively can significantly enhance the reliability and maintainability of your tests.
before
A classic use case of the before
hook is to create a set of data before running a test. Here is a use case: we want to write a scope
in the User model that will only return users with the role of 'admin'.
describe User do
describe '.admins' do
let!(:admin_user) { User.create(name: 'bob', role: 'admin') }
before { User.create(name: 'jane') }
it { expect(User.admins).to eq([admin_user]) }
end
end
In this case, we chose not to care about the non-admin user. That use is considered "noise": we want to have something else in the database, but we don't need to have a variable attached to it.
More on matchers
RSpec comes with a lot of matchers by default. If you add the ones provided in the rspec-rails
gem you have pretty much everything you need. There is a possibility to add custom ones, but let's first see a handful of the standard ones you need to know about.
- testing identity:
be()
expect(actual).to be(expected)
- testing equivalence:
eq()
expect(actual).to eq(expected)
- comparing with
<
,>
,<=
, and>=
expect(actual).to be <= expected
- comparing with
be_between
with eitherinclusive
,exclusive
as complementexpect(actual).to be_between(minimum, maximum).inclusive
- matching a regular expression with
match
expect(actual).to match(/expression/)
- comparing within a delta of the expected value with
be_within(delta)
expect(actual).to be_within(0.1).of(expected)
- checking that a string starts or ends with a specific string with
start_with
andend_with
expect(actual).to start_with('this')
- testing that an object is of specific class with
be_instance_of
,be_kind_of
expect(actual).to be_instance_of(String)
- testing that a method or attribute exists for an object or class with
respond_to
expect(actual).to respond_to(:method_name)
- testing that a variable exist with
exist
expect(actual).to exist
- testing truthiness with
be_truthy
(neithernil
norfalse
) andbe_falsey
(nil
orfalse
)expect(actual).to be_truthy
- testing
true/false
withbe(true)
andbe(false)
expect(actual).to be(true)
- testing for
nilness
withbe_nil
orbe(nil)
expect(actual).to be_nil
- testing for emptiness (of an array or collection) with
be_empty
expect(actual).to be_empty
- testing if a hash has a specific key with
have_key(:a)
expect({ a: 1 }).to have_key(:a)
- testing for inclusion in a string, array, or collection with
include
expect([1, 2, 3]).to include(1)
- testing for an exact match between two arrays with
match_array
expect([1, 2]).to match_array([1, 2])
- expecting errors with
raise_error
expect { object.method_with_raise }.to raise_error(OptionalErrorClass)
- testing for change with
change
expect { User.create(name: 'bob') }.to change { User.count }.by(1)
There is even more to this, and you can check the official RSpec documentation on relishapp.com : https://relishapp.com/rspec/rspec-expectations/docs/built-in-matchers.
Wrapping up
Understanding and mastering the concepts of contexts, let
& let!
, hooks, and base matchers are essential for writing effective and reliable tests using RSpec. These features help us create organized and readable tests while keeping our test code DRY and duplication-free.
By using contexts, we can structure our tests according to the different scenarios they cover.
let
and let!
allow us to define variables and objects used across multiple tests while ensuring they are correctly initialized.
Hooks provide us with a way to execute code before and after our tests, helping us manage setup and teardown of resources.
Finally, the base matchers are the building blocks of our assertions, allowing us to test for equality, truthiness, inclusion, and more.
Now that we have covered those, we can move on to more advanced topics such as shared examples, custom matchers, and testing external dependencies. By improving our knowledge and skills with RSpec, we can write tests that accurately reflect the behavior of our code and provide confidence in our software.
Ready to go further?
If you want to dive deeper into RSpec and take your testing skills to the next level, consider checking out our comprehensive eBook, "Learn RSpec: mastering tests for Ruby applications". This guide will provide you with all the tools and knowledge you need to write effective tests using RSpec confidently. Whether a beginner or an experienced developer, our eBook will walk you through creating and running tests with RSpec from start to finish. You'll learn to use contexts, let
and let!
, hooks, and base matchers to write clean and maintainable tests.
If you're interested in taking your RSpec skills even further, consider joining our mentoring program or signing up for our workshop and training program for your team. Our experienced mentors will work with you one-on-one to help you solve specific testing challenges and provide guidance on best practices. Our workshop and training program will provide your team with the tools and knowledge they need to write effective tests using RSpec, so you can ensure the quality and reliability of your codebase. Don't hesitate to contact us to learn more about our services.