If you are using RSpec and are thinking that you’ve never used metadata
, chances are that you have already been using them without even knowing.
In this blog post, let’s discuss what RSpec metadata is and how can we can have our own to make our specs more readable and clean.
What is Metadata?
Metadata is basically a set of additional instructions (typically a Ruby hash) that you can send to a spec that it can then use. At a high level, this extra information can be used to:
- Load additional files
- Set values
- Do actions (e.g before
actions), etc.
But You Said I Might be Already Using It?
Yes, I did. So, let’s dive into some examples. When writing specs, you may be familiar with the following syntax: Example 1. using metadata in RSpec itself
ruby
# https://relishapp.com/rspec/rspec-rails/docs/model-specs
require "rails_helper"
RSpec.describe Post, type: :model do
context "with 2 or more comments" do
it "orders them in reverse chronologically" do
# your test code here
end
end
end
The part type: :model
is the metadata. It sends additional instructions to the spec. In this case, it tells the spec “this is a model test”. In doing so, the spec will then know what libraries need to be loaded to work with model tests.
In RSpec, you have two ways of letting the framework know what kind of spec you are running:
- By structuring your spec in the correct folders. For example, model specs should go in
spec/models/
- Letting RSpec know explicitly by sending the type as a
metadata
argument, as in the above example. Below is an oversimplified version of how this works. Note that this is from RSpec’s master branch, so it could change over time. - Sets the type (either from the folder structure, via DIRECTORY_MAPPINGS hash) or ametadata[:type]
:
ruby
# https://github.com/rspec/rspec-rails/blob/master/lib/rspec/rails/configuration.rb#L111
define_derived_metadata(:file_path => escaped_path) do |metadata|
metadata[:type] ||= type
end
- Then based on the type, loads necessary helpers:
ruby
# https://github.com/rspec/rspec-rails/blob/master/lib/rspec/rails/configuration.rb#L7
# Automatically tag specs in conventional directories with matching `type`
# metadata so that they have relevant helpers available to them
Example 2. Capybara The Capybara gem is another good (and easy to understand) example of how metadata can be used to give extra information. From the Capybara README:
# https://github.com/teamcapybara/capybara#using-capybara-with-rspec
Use js: true to switch to the Capybara.javascript_driver (:selenium by default), or provide a :driver option to switch to one specific driver.
By way of an example:
ruby
# https://github.com/teamcapybara/capybara#using-capybara-with-rspec
describe 'some stuff which requires js', js: true do
it 'will use the default js driver'
it 'will switch to one specific driver', driver: :apparition
end
And this has been implemented as:
ruby
# https://github.com/teamcapybara/capybara/blob/master/lib/capybara/rspec.rb#:L23
config.before do |example|
if self.class.include?(Capybara::DSL)
Capybara.current_driver = Capybara.javascript_driver if example.metadata[:js]
Capybara.current_driver = example.metadata[:driver] if example.metadata[:driver]
end
end
As you can see on the above code, if the user passes metadata as js
, it will load the Capybara.javascript_driver
as the current driver.
Cool story… But you Said I Can Implement This Too?
Yes, I did. Let’s go through how can we use our own custom metadata. For this, we’ll consider a very basic example.
Example app
An app that has two types of user roles. user
(normal user) and admin
. When logged in:
- users
can see Profile link
.
- admins
can see, Profile link
and also Settings link
.
We’ll first write a very basic request spec for this. (At this point I’m assuming the project is set up with RSpec, FactoryBot, Capybara, etc..)
ruby
# spec/factories/users.rb
FactoryBot.define do
factory :user do
...
end
factory :admin, class: User do
...
end
end
And our request spec would look like this:
ruby
# spec/features/login_feature_spec.rb
require 'rails_helper'
RSpec.feature 'Login', type: :feature do
before do
sign_in user
visit root_path
end
context 'user login' do
let!(:user) { create :user }
it 'shows the correct navigation links' do
expect(page).to have_link('Profile')
expect(page).not_to have_link('Settings')
end
end
context 'admin login' do
let!(:user) { create :admin }
it 'shows the correct navigation links' do
expect(page).to have_link('Profile')
expect(page).to have_link('Settings')
end
end
end
# Finished in 0.17118 Seconds (files took 0.69104 seconds to load)
2 examples, 0 failures
Now, let’s try to implement this using metadata. As you can see in this example, the common part for both of these specs is the user login. Basically, we pass a user object and it logs in that user. It would be nice to read the specs as:
ruby
RSpec.feature 'Login', type: :feature do
context 'user login', login_as: :user do
...
end
context 'admin login', login_as: :admin do
...
end
end
Now, let’s see how we can achieve this. As you can see, we pass the data as login_as: :user
where login_as
is the key and :user
is the value. In our case, the value will be directly matched to a FactoryBot factory.
We will first create a separate support file to handle the login. NOTE: you can add the following code to either rails_helper
or spec_helper
, but I would highly recommend you have it in a separate file for clarity and easy maintenance in the future.
Create a support file spec/support/login.rb
, and add the following code:
ruby
RSpec.configure do |config|
config.before do |example|
meta = example.metadata
if meta[:type] == :feature && meta[:login_as]
sign_in create(meta[:login_as])
visit root_path
end
end
end
I’ll break the code down for you
- The
RSpec.configure
block runs for each example, so we need to enclose our code inside that configure block. - On the next line
config.before
we tell RSpec to run this code before each example. The options are:config.before
(the one we have)config.around
config.after
- The next important line is
if meta[:type] == :feature && meta[:login_as]
. Here we check if the spec is afeature spec
and also if thelogin_as
metadata is given. We can just go bymeta[:login_as]
, but I’ve added meta[:type] just as an extra check. - The rest is a pretty standard code, where we create and sign in the user factory from the
metadata
value and redirect the user to the root_path. The important takeaway of this code is:
ruby
RSpec.configure do |config|
config.<your execution type> do |example|
# Your code to handle the example.metadata.
end
end
With all that done, the final code will be:
ruby
# spec/support/login.rb
RSpec.configure do |config|
config.before do |example|
meta = example.metadata
if meta[:type] == :feature && meta[:login_as]
sign_in create(meta[:login_as])
visit root_path
end
end
end
# spec/features/login_feature_spec.rb
require 'rails_helper'
RSpec.feature 'Login', type: :feature do
context 'user login', login_as: :user do
it 'shows the correct navigation links' do
expect(page).to have_link('Profile')
expect(page).not_to have_link('Settings')
end
end
context 'admin login', login_as: :admin do
it 'shows the correct navigation links' do
expect(page).to have_link('Profile')
expect(page).to have_link('Settings')
end
end
end
Finished in 0.21993 seconds (files took 4.63 seconds to load)
2 examples, 0 failures
Awesome. I’m Going to Be Using metadata
for Everything from Here on Out
Hmm, maybe not! You see, metadata
is a very powerful concept in RSpec, however, like everything in life, overusing it will make your specs unreadable and hard to maintain.
Ideally, a spec should have everything in one file for easy reading/reference. If not, It’s important to make sure you can read and understand the spec without switching between files.
I personally think, context 'admin login', login_as: :admin
reads better and makes it easier to understand what is expected in the spec. However, I wouldn’t go and add multiple metadata as it will make the spec too complex and hard to understand.
Any Final Words?
Yes. Now you know about RSpec metadata (if you didn’t already). Remember this is another option you can use to write more readable specs. But the most important thing is to understand when to use it.
I personally believe that using this the right way will make your specs much cleaner and easy to understand.
Happy coding…