[Rails Notes] DRY and Reusable RSpec Tests with `shared_examples` and `shared_context`
I’ve been working with Ruby on Rails since I started my current job. One of the biggest pains of our team was to keep high test coverage without writing repetitive tests. Here’s some notes of what I’ve learned.
The Problem
As a Rails developer, I would like to write DRY, reusable RSpec tests, so that I can better organize my tests, and don’t need to repeat writing duplicate tests. Meanwhile, I don’t want to trade test coverage for that.
Short Answer
shared_context
to define common set up tasks.shared_examples
to include shared examples and expectations
Common Syntax and Basic Usage
Note: There are multiple legal syntaxes and usages, only the most common ones are listed here.
shared_context
- Definition
shared_context 'context description' do
# do whatever
end
# or with parameter:
shared_context 'context description' do |param1, param2, ..|
# do whatever
end
- Usage
include_context 'context description'
# or with parameter:
include_context 'context description', param1, param2, ..
shared_examples
- Definition
shared_examples 'shared_examples name' do
# do whatever
end
# or with parameter:
shared_examples 'shared_examples name' do |param1, param2, ..|
# do whatever
end
- Usage
include_examples 'shared_examples name' (,optional_params) # include the examples in the current context
#or
it_should_behave_like 'shared_examples name' (,optional_params) # include the examples in a nested context
Code Examples
Context
In this brief demo, I’m building a simple application with only 1 entity – User
– for demo purpose. In this section, I’ll only include model
and controller
code (as well as their specs).
Note: All code examples are purely for demo purpose. They may not be 100% correct or accurate, and they are not optimized.
Model
User model has only 4 fields – :name
, :age
, :vip
, :bio
# In db/schema.rb
create_table "users", force: :cascade do |t|
t.string "name", null: false
t.integer "age", null: false
t.boolean "vip", null: false
t.string "bio"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
# In models/user.rb
class User < ActiveRecord::Base
validates :name, length: { minimum: 2 }, presence: true
validates :bio, length: { maximum: 100 }
validates :age, numericality: { only_integer: true, greater_than: 0, less_than: 150 }, presence: true
validates :vip, inclusion: { in: [true, false] }
end
Model specs
# In specs/models/user_spec.rb
require 'rails_helper'
RSpec.describe User, type: :model do
it { should validate_presence_of(:name) }
it { should validate_length_of(:name).is_at_least(2) }
it { should validate_length_of(:bio).is_at_most(100) }
it { should validate_presence_of(:age) }
it { should validate_numericality_of(:age).is_greater_than(0).is_less_than(150) }
it { should validate_inclusion_of(:vip).in_array([true, false]) }
# make sure default values are valid
let(:name) { 'Tracy' }
let(:bio) { 'Some Bio Text' }
let(:age) { 28 }
let(:vip) { false }
let(:user) { User.new(name: name, bio: bio, age: age, vip: vip) }
describe 'valid?' do
subject { user.valid? }
shared_examples 'record is valid' do |valid|
it { expect(subject).to be valid }
end
describe '#name' do
shared_context 'set name' do |name|
let(:name) { name }
end
context 'when more than 2 characters' do
# another way is directly set :name here, rather than delegate to the
# parameterized shared_context 'set name', like in "describe '#age'" block
include_context 'set name', 'Sky'
include_examples 'record is valid', true
end
context 'when equals to 2 characters' do
include_context 'set name', 'Ed'
include_examples 'record is valid', true
end
context 'when less than 2 characters' do
include_context 'set name', 'a'
include_examples 'record is valid', false
end
context 'when empty' do
include_context 'set name', ''
include_examples 'record is valid', false
end
context 'when nil' do
include_context 'set name', nil
include_examples 'record is valid', false
end
end
describe '#age' do
context 'when between 0 and 150' do
let(:age) { 42 }
include_examples 'record is valid', true
end
context 'when equals to 0' do
let(:age) { 0 }
include_examples 'record is valid', false
end
context 'when less than 0' do
let(:age) { -2 }
include_examples 'record is valid', false
end
context 'when equals to 150' do
let(:age) { 150 }
include_examples 'record is valid', false
end
context 'when more than 150' do
let(:age) { 155 }
include_examples 'record is valid', false
end
context 'when with fraction' do
let(:age) { 31.7 }
include_examples 'record is valid', false
end
end
...
end
end
Notes: The model specs are relatively simple
Controller
Note:
For brevity, only showing a few methods here, for demo purpose
class UsersController < ApplicationController
before_action :set_user, only: [:show, :edit, :update, :destroy]
def index
@users = User.all
end
def show
end
def create
@user = User.new(user_params)
respond_to do |format|
if @user.save
format.html { redirect_to @user, notice: 'User was successfully created.' }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
...
end
Controller specs
require 'rails_helper'
RSpec.describe UsersController, type: :controller do
shared_context 'create users' do
let(:user1) { User.create(name: 'user1', bio: 'some text', age: 18, vip: true) }
let(:user2) { User.create(name: 'user2', age: 26, vip: false) }
end
# These examples are shared across all requests
shared_examples 'renders template' do |template_name|
it { expect(response).to render_template(template_name) }
end
shared_examples 'respond_with' do |status|
it { expect(response).to have_http_status(status) }
end
describe 'GET index' do
shared_context 'GET index' do
before { get :index }
end
# Expectations of GET :index request
shared_examples 'GET index renders index view' do
include_context 'GET index'
include_examples 'respond_with', :ok
include_examples 'renders template', 'index'
end
context 'when no user exist' do
include_examples 'GET index renders index view'
end
context 'when users exist' do
include_context 'create users'
include_examples 'GET index renders index view'
it { expect(assigns(:users)).to eq([user1, user2]) }
end
end
describe 'GET show' do
include_context 'create users'
shared_context 'GET show' do
before { get :show, id: user_id }
end
# Expectations of successful GET :show request
shared_examples 'request is successful' do
include_context 'GET show'
include_examples 'respond_with', :ok
include_examples 'renders template', 'show'
it { expect(assigns(:user)).to eq(user) }
end
# Expectations of unsuccessful GET :show request
shared_examples 'request is not successful' do
include_context 'GET show'
include_examples 'respond_with', :not_found
end
context 'when user exists' do
let(:user) { user1 }
let(:user_id) { user1.id }
include_examples 'request is successful'
end
context 'when user does not exist' do
let(:user_id) { 999 }
include_examples 'request is not successful'
end
end
describe 'POST create' do
shared_context 'POST create' do
before { post :create, user: params }
end
shared_examples 'request is successful' do
include_context 'POST create'
include_examples 'respond_with', :found
it { expect(response).to redirect_to assigns(:user) }
end
shared_examples 'request is not successful' do
include_context 'POST create'
include_examples 'respond_with', :unprocessable_entity
include_examples 'renders template', 'new'
end
context 'when params are all valid' do
let(:params) do
{ name: 'Alex', bio: 'hello world', age: 34, vip: false }
end
include_examples 'request is successful'
end
context 'when params are invalid' do
context 'when name is invalid' do
let(:params) do
{ name: 'z', bio: 'hello world', age: 34, vip: false }
end
include_examples 'request is not successful'
end
context 'when age is invalid' do
let(:params) do
{ name: 'Becca', bio: 'hello world', age: 0, vip: true }
end
include_examples 'request is not successful'
end
end
end
...
end
Notes: In examples here, the organization of shared_examples are based on whether the request is successful or not. For example, a successful GET :show
request is expected to render show
view, with 200
status code; but in a failed case, it is expected to render 404
.
Running tests in documentation format
Running rspec
with -f d
option will output results in a more structured, human-readable way. For example, running:
rspec spec/controllers/users_controller_spec.rb -f d
will output:
UsersController
GET index
when no user exist
should respond with status code :ok (200)
should render template index
when users exist
should respond with status code :ok (200)
should render template index
should eq [#<User id: 1, name: "user1", age: 18, vip: true, bio: "some text", created_at: "2019-02-24 01:47:50"...ge: 26, vip: false, bio: nil, created_at: "2019-02-24 01:47:50", updated_at: "2019-02-24 01:47:50">]
GET show
when user exists
should respond with status code :ok (200)
should render template show
should eq #<User id: 1, name: "user1", age: 18, vip: true, bio: "some text", created_at: "2019-02-24 01:47:50", updated_at: "2019-02-24 01:47:50">
when user does not exist
should respond with a not_found status code (404)
POST create
when params are all valid
should respond with status code :found (302)
should redirect to #<User id: 1, name: "Alex", age: 34, vip: false, bio: "hello world", created_at: "2019-02-24 01:47:50", updated_at: "2019-02-24 01:47:50">
when params are invalid
when name is invalid
should respond with status code :unprocessable_entity (422)
should render template new
when age is invalid
should respond with status code :unprocessable_entity (422)
should render template new
Finished in 0.09021 seconds (files took 1.31 seconds to load)
15 examples, 0 failures
Takeaways
- Summarized by my former teammate Tom:
Using shared_context to replay setup, and shared_examples to replay expectations makes writing tests so much better.
-
DRY, reusable tests are possible without worrying about losing test coverage.
Related Links
- Official doc of RSpec shared_context
- Official doc of RSpec shared_examples
include_examples
vsit_behaves_like
– StackOverflow post- Better Specs – Shared Examples