[Rails Notes] DRY and Reusable RSpec Tests with `shared_examples` and `shared_context`

[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

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

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.