Initial setup

I was working on a Rails project the other day and had to write a few tests which were very similar to each other. This sent my senses tingling and a sudden urge to DRY up my code swept through me. I started wondering if there was any way I could use RSpec's shared examples to do this.

For this demo, I've kept it simple with a User model and a Post model. Let's say that users can have roles such as Bronze, Silver and Gold. Each role has its own limitations - Bronze users can only create 2 posts, Silver users can create 4, and Gold users can create 6.

This is what I was working with.

# app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  enum role: {
    bronze: 0,
    silver: 1,
    gold: 2
  }

  has_many :posts
end
# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
  validates_with RoleValidator
end
# app/models/concerns/role_validator.rb
class RoleValidator < ActiveModel::Validator
  def validate(record)
    user = record.user

    if user.bronze? && user.posts.count >= 2
      record.errors.add :base, 'Limit reached.'
    elsif user.silver? && user.posts.count >= 4
      record.errors.add :base, 'Limit reached.'
    elsif user.gold? && user.posts.count >= 6
      record.errors.add :base, 'Limit reached.'
    end
  end
end

Let's get testing

I know the code below is really long. Don't be put off. Just try to make sense of the first two tests and you'll see that the other tests are very similar. Here goes 🫣

# spec/models/post_spec.rb
require 'rails_helper'

RSpec.describe Post, type: :model do
  describe 'Post validations' do
    context 'when bronze user' do
      user = User.create(email: 'user1@email.com', password: 'password', role: :bronze)
      it 'is valid when post count is below the limit (2)' do
        Post.create(user:)
        post = Post.new(user:)

        assert post.valid?
      end

      it 'is invalid when post count is at or above the limit (2)' do
        2.times { Post.create(user:) }
        post = Post.new(user:)

        refute post.valid?
      end
    end

    context 'when silver user' do
      user = User.create(email: 'user2@email.com', password: 'password', role: :silver)
      it 'is valid when post count is below the limit (4)' do
        3.times { Post.create(user:) }
        post = Post.new(user:)

        assert post.valid?
      end

      it 'is invalid when post count is at or above the limit (4)' do
        4.times { Post.create(user:) }
        post = Post.new(user:)

        refute post.valid?
      end
    end

    context 'when gold user' do
      user = User.create(email: 'user3@email.com', password: 'password', role: :gold)
      it 'is valid when post count is below the limit (6)' do
        5.times { Post.create(user:) }
        post = Post.new(user:)

        assert post.valid?
      end

      it 'is invalid when post count is at or above the limit (6)' do
        6.times { Post.create(user:) }
        post = Post.new(user:)

        refute post.valid?
      end
    end
  end
end

As I was writing these tests I began noticing there was some repetition of the code with some small differences. I decided to try DRYing up the code with a shared example.

Introducing RSpec shared examples

In all the role contexts, we are first creating posts until the post count is 1 below the limit. Then we create posts until the posts count is at the limit.

I also noticed that the only things changing between the tests were the limits and the roles of the user. So I extracted the rest of it into a shared example and passed in the limit and the user in the params. Remember the big block of code from earlier? It looks like this now. Not so scary now huh! 😎

# spec/models/post_spec.rb
require 'rails_helper'

RSpec.describe Post, type: :model do
  describe 'Post validations' do
    context 'when bronze user' do
      user = User.create(email: 'user1@email.com', password: 'password', role: :bronze)
      it_behaves_like 'post', {limit: 2, user: }
    end

    context 'when silver user' do
      user = User.create(email: 'user2@email.com', password: 'password', role: :silver)
      it_behaves_like 'post', {limit: 4, user: }
    end

    context 'when gold user' do
      user = User.create(email: 'user3@email.com', password: 'password', role: :gold)
      it_behaves_like 'post', {limit: 6, user: }
    end
  end
end
# spec/support/post_examples_spec.rb
RSpec.shared_examples 'post' do |params|
  limit = params[:limit]
  user = params[:user]

  it 'is valid when post count is below the limit' do
    (limit - 1).times { Post.create(user:) }
    post = Post.new(user:)

    assert post.valid?
  end

  it 'is invalid when post count is at or above the limit' do
    limit.times { Post.create(user:) }
    post = Post.new(user:)

    refute post.valid?
  end
end

Other opportunities for refactoring

I can refactor even more by using constants for the limits. I created a constants.rb file to hold these and any future constants.

# config/initializers/constants.rb
BRONZE_LIMIT = 2
SILVER_LIMIT = 4
GOLD_LIMIT = 6
# app/models/concerns/role_validator.rb
class RoleValidator < ActiveModel::Validator
  def validate(record)
    user = record.user

    if user.bronze? && user.posts.count >= BRONZE_LIMIT
      record.errors.add :base, 'Limit reached.'
    elsif user.silver? && user.posts.count >= SILVER_LIMIT
      record.errors.add :base, 'Limit reached.'
    elsif user.gold? && user.posts.count >= GOLD_LIMIT
      record.errors.add :base, 'Limit reached.'
    end
  end
end
# spec/models/post_spec.rb
require 'rails_helper'

RSpec.describe Post, type: :model do
  describe 'Post validations' do
    context 'when bronze user' do
      user = User.create(email: 'user1@email.com', password: 'password', role: :bronze)
      it_behaves_like 'post', {limit: BRONZE_LIMIT, user: }
    end

    context 'when silver user' do
      user = User.create(email: 'user2@email.com', password: 'password', role: :silver)
      it_behaves_like 'post', {limit: SILVER_LIMIT, user: }
    end

    context 'when gold user' do
      user = User.create(email: 'user3@email.com', password: 'password', role: :gold)
      it_behaves_like 'post', {limit: GOLD_LIMIT, user: }
    end
  end
end

This means that if the limits need to be changed in the future, I only have to change it in one place. And guess what? No need to spend valuable developer time updating the tests because they don't even need to be changed. They'll be passing with flying colors!

Oh also! Another opportunity for refactoring is to use a gem like FactoryBot to create the User records instead. But I'm skipping that for now as it's not in the scope of this article and my coffee is calling me.

Enjoy the time saved and the clean code! ☕️