Factory Bot With Rails

At this stage of the project, we are introducing Factory Bot to help replace the default fixtures so we can test our PostsRepository code.

On this page

This post will continue on the work that we have done for the last two posts by introducing Factory Bot to help with writing our tests for the Posts repository.

Source code can be found here

Prerequisites

  1. Part 1
  2. Part 2

Getting started

We will clone the project demo-repo-pattern-dry-validation and work from the branch dry-validation-and-refactor:

# Clone the project
$ git https://github.com/okeeffed/demo-repo-pattern-dry-validation
$ cd demo-repo-pattern-dry-validation
$ git checkout dry-validation-and-refactor

# Install dependencies
$ bundler install factory_bot_rails --group "development,test"

# Create files required
$ mkdir -p spec/factories spec/repositories
$ touch spec/factories/post_factory.rb spec/repositories/posts_repository_spec.rb

Following on from the Factory Bot Getting Started docs for RSpec, we need to add the following to spec/support/factory_bot.rb:

RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods
end

Then in the spec/rails_helper.rb file, uncomment the line Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }.

At this stage, our project is now ready to start creating our factory.

Post factory

Inside of spec/factories/post_factory.rb, add the following:

FactoryBot.define do
  factory :post do
    title { "Title" }
    rating  { "Good" }
  end
end

Now that our factory is defined, we are ready to write some tests for our PostsRespository class.

A quick refactor

Something came up between the last blog post and this one was that testing the PostsRespository class on its own will not include our dry-validation PostContract validator that we wrote in the last post.

The jury is still out on whether the decision is the right one, but I decided to refactor both our controller and repository classes so that the validation occurred in the repository class.

Ensure that you update your app/repositories/posts_repository to the following:

require 'dry/monads'
require 'post_contract'

Dry::Validation.load_extensions(:monads)

class PostsRepository
  class << self
    include Dry::Monads[:result]

    def create(title:, rating:)
      # Validate the params
      contract = PostContract.new
      validation = contract.call(title: title, rating: rating).to_monad
      return validation unless validation.success?

      post = Post.new(title: title, rating: rating)
      post.save!

      # `save!`` will raise if there is an error, so we assume here
      # that we were successful and return the post to follow RESTful conventions.
      # @see https://restfulapi.net/http-status-200-ok/
      Success(post)
    rescue ActiveRecord::ActiveRecordError => e
      Failure(e)
    rescue StandardError => e
      Failure(e)
    end

    def find_all
      posts = Post.all
      Success(posts)
    rescue ActiveRecord::ActiveRecordError => e
      Failure(e)
    rescue StandardError => e
      Failure(e)
    end

    def find_by_id(id:)
      post = Post.find_by!(id: id)
      Success(post)
    rescue ActiveRecord::RecordNotFound => e
      Failure(e)
    rescue ActiveRecord::ActiveRecordError => e
      Failure(e)
    rescue StandardError => e
      Failure(e)
    end
  end
end

Essentially we are adding the first three lines of the create function to now validate our parameters coming in.

Because of that, I have no refactored the file app/controllers/posts_controller.rb file to the following:

require 'dry/validation'
require 'dry/monads'
require 'posts_repository'

class PostsController < ApplicationController
  include Dry::Monads[:result, :do]

  def create
    case PostsRepository.create(title: params[:title], rating: params[:rating])
    in Success(post) then render json: post, status: :ok
    in Failure(ActiveRecord::ActiveRecordError) then internal_server_error(code: '002')
    in Failure(StandardError) then internal_server_error(code: '001')
    in Failure(Dry::Validation::Result => result) then unprocessable_entity(result: result)
    else internal_server_error(code: '003')
    end
  end

  def index
    case PostsRepository.find_all
    in Success(posts) then render json: posts, status: :ok
    in Failure(ActiveRecord::ActiveRecordError) then internal_server_error(code: '002')
    in Failure(StandardError) then internal_server_error(code: '001')
    else internal_server_error(code: '003')
    end
  end

  def show
    case PostsRepository.find_by_id(id: params[:id])
    in Success(post) then render json: post, status: :ok
    in Failure(ActiveRecord::RecordNotFound) then not_found
    in Failure(ActiveRecord::ActiveRecordError) then internal_server_error(code: '002')
    in Failure(StandardError) then internal_server_error(code: '001')
    else internal_server_error(code: '003')
    end
  end

  private

  def internal_server_error(code:)
    render json: { message: 'Internal server error', code: code }, status: :internal_server_error
  end

  def unprocessable_entity(result:)
    render json: { message: 'Unprocessable entity', errors: result.errors.to_h }, status: :unprocessable_entity
  end

  def not_found
    render json: { message: 'Not found' }, status: :not_found
  end
end

There changes I made here were to remove the private method create_post and just run the pattern matching on the create method directly on the result of PostsRepository.create.

With these changes in place, there were a couple of updates that I needed to make to the controller tests. From memory, I needed to update the tests that returned 422 to actually run against the validation without a mock. For reference sake, my spec/controllers/posts_controller_spec.rb file looks like the following:

require 'rails_helper'
require 'dry/monads'
# require 'dry/validation'
require 'post_contract'
require 'posts_repository'
require 'pry'

# Dry::Validation.load_extensions(:monads)

RSpec.describe PostsController, type: :controller do
  include Dry::Monads[:result]

  describe 'GET #index' do
    context 'successful responses' do
      it 'returns empty successful array when there are no posts' do
        stubbed_response = []
        posts_respository_klass = class_double(PostsRepository, find_all: Success([stubbed_response])).as_stubbed_const

        expect(posts_respository_klass).to receive(:find_all)

        get :index
        expect(response).to have_http_status(:success)
        expect(response.parsed_body).to eq(JSON.parse(stubbed_response.to_json))
      end

      it 'returns correct array when there are posts' do
        posts = [{
          id: 1,
          title: 'Cool post',
          rating: 'Good'
        }]
        posts_respository_klass = class_double(PostsRepository, find_all: Success([posts])).as_stubbed_const

        expect(posts_respository_klass).to receive(:find_all)

        get :index
        expect(response).to have_http_status(:success)
        expect(response.parsed_body).to eq(JSON.parse(posts.to_json))
      end
    end

    context 'unsuccessful responses' do
      it 'returns 500 when there is an ActiveRecord::ActiveRecordError' do
        res = { message: 'Internal server error', code: '002' }
        posts_respository_klass = class_double(PostsRepository,
                                               find_all: Failure(ActiveRecord::ActiveRecordError.new)).as_stubbed_const

        expect(posts_respository_klass).to receive(:find_all)

        get :index
        expect(response).to have_http_status(:internal_server_error)
        expect(response.parsed_body).to eq(JSON.parse(res.to_json))
      end

      it 'returns 500 when there is a StandardError' do
        res = { message: 'Internal server error', code: '001' }
        posts_respository_klass = class_double(PostsRepository, find_all: Failure(StandardError.new)).as_stubbed_const

        expect(posts_respository_klass).to receive(:find_all)

        get :index
        expect(response).to have_http_status(:internal_server_error)
        expect(response.parsed_body).to eq(JSON.parse(res.to_json))
      end
    end
  end

  describe 'GET #show' do
    context 'successful responses' do
      it 'returns correct array when there is a post with the expected ID' do
        stubbed_response = {
          id: 1,
          title: 'Cool post',
          rating: 'Good'
        }
        posts_respository_klass = class_double(PostsRepository, find_by_id: Success(stubbed_response)).as_stubbed_const

        expect(posts_respository_klass).to receive(:find_by_id).with(id: '1')

        get :show, params: { id: '1' }
        expect(response).to have_http_status(:success)
        expect(response.parsed_body).to eq(JSON.parse(stubbed_response.to_json))
      end
    end

    context 'unsuccessful responses' do
      it 'returns 500 when there is an ActiveRecord::ActiveRecordError' do
        res = { message: 'Internal server error', code: '002' }
        posts_respository_klass = class_double(PostsRepository,
                                               find_by_id: Failure(ActiveRecord::ActiveRecordError.new)).as_stubbed_const

        expect(posts_respository_klass).to receive(:find_by_id).with(id: '1')

        get :show, params: { id: '1' }
        expect(response).to have_http_status(:internal_server_error)
        expect(response.parsed_body).to eq(JSON.parse(res.to_json))
      end

      it 'returns 500 when there is a StandardError' do
        res = { message: 'Internal server error', code: '001' }
        posts_respository_klass = class_double(PostsRepository, find_by_id: Failure(StandardError.new)).as_stubbed_const

        expect(posts_respository_klass).to receive(:find_by_id).with(id: '1')

        get :show, params: { id: '1' }
        expect(response).to have_http_status(:internal_server_error)
        expect(response.parsed_body).to eq(JSON.parse(res.to_json))
      end

      it 'returns 404 when there is no matching post' do
        res = { message: 'Not found' }
        posts_respository_klass = class_double(PostsRepository).as_stubbed_const
        allow(posts_respository_klass).to receive(:find_by_id).and_return(Failure(ActiveRecord::RecordNotFound.new))

        expect(posts_respository_klass).to receive(:find_by_id).with(id: '1')

        get :show, params: { id: '1' }
        expect(response).to have_http_status(:not_found)
        expect(response.parsed_body).to eq(JSON.parse(res.to_json))
      end
    end
  end

  describe 'POST #create' do
    context 'successful responses' do
      it 'returns 200 and created post when the post is valid' do
        stubbed_response = { id: 1, title: 'Title', rating: 'Good' }
        posts_respository_klass = class_double(PostsRepository, create: Success(stubbed_response)).as_stubbed_const

        expect(posts_respository_klass).to receive(:create).with(title: 'Title', rating: 'Good')

        post :create, params: { title: 'Title', rating: 'Good' }, as: :json
        expect(response).to have_http_status(:ok)
        expect(response.parsed_body).to eq(JSON.parse(stubbed_response.to_json))
      end
    end

    context 'invalid params' do
      it 'returns 422 when there are invalid params for title' do
        res = { message: 'Unprocessable entity', errors: { title: ['must be a string'] } }

        post :create, params: { title: 100, rating: 'Good' }, as: :json
        expect(response).to have_http_status(:unprocessable_entity)
        expect(response.parsed_body).to eq(JSON.parse(res.to_json))
      end

      it 'returns 422 when there are invalid params for rating' do
        res = { message: 'Unprocessable entity', errors: { rating: ['must be a string'] } }

        post :create, params: { title: 'Title', rating: 100 }, as: :json
        expect(response).to have_http_status(:unprocessable_entity)
        expect(response.parsed_body).to eq(JSON.parse(res.to_json))
      end
    end

    context 'unsuccessful responses' do
      it 'returns 500 when there is an ActiveRecord::ActiveRecordError' do
        res = { message: 'Internal server error', code: '002' }
        posts_respository_klass = class_double(PostsRepository,
                                               create: Failure(ActiveRecord::ActiveRecordError.new)).as_stubbed_const

        expect(posts_respository_klass).to receive(:create).with(title: 'Title', rating: 'Good')

        post :create, params: { title: 'Title', rating: 'Good' }, as: :json
        expect(response).to have_http_status(:internal_server_error)
        expect(response.parsed_body).to eq(JSON.parse(res.to_json))
      end

      it 'returns 500 when there is a StandardError' do
        res = { message: 'Internal server error', code: '001' }
        posts_respository_klass = class_double(PostsRepository, create: Failure(StandardError.new)).as_stubbed_const

        expect(posts_respository_klass).to receive(:create).with(title: 'Title', rating: 'Good')

        post :create, params: { title: 'Title', rating: 'Good' }, as: :json
        expect(response).to have_http_status(:internal_server_error)
        expect(response.parsed_body).to eq(JSON.parse(res.to_json))
      end

      it 'returns 500 when there is an unexpected state' do
        res = { message: 'Internal server error', code: '003' }
        posts_respository_klass = class_double(PostsRepository, create: Failure(Exception.new)).as_stubbed_const

        expect(posts_respository_klass).to receive(:create).with(title: 'Title', rating: 'Good')

        post :create, params: { title: 'Title', rating: 'Good' }, as: :json
        expect(response).to have_http_status(:internal_server_error)
        expect(response.parsed_body).to eq(JSON.parse(res.to_json))
      end
    end
  end
end

Now we can get on to writing our repository tests!

The Posts Repository Spec

Thanks to our post factory, we can use some of the Factory Bot helper methods to test our repository.

With Factory Bot, we have two methods to create a post:

  1. build(:post) where the model is not saved.
  2. create(:post) where the model is saved.

With the tests that I am writing, I will essentially be using build to create an object quickly to test for our PostsRepository#create method while the Factory Bot create helper I will use for the tests that require fetching a value from the database.

In the spec/repositories/posts_repository_spec.rb file, add the following:

require 'rails_helper'
require 'posts_repository'

RSpec.describe PostsRepository do
  describe '#create' do
    context 'valid parameters' do
      let(:subject) { build(:post) }

      it 'returns a post' do
        result = PostsRepository.create(title: subject.title, rating: subject.rating)
        expect(result.success?).to eq(true)

        post = result.value!
        expect(post).to be_a(Post)
        expect(post.title).to eq(subject.title)
        expect(post.rating).to eq(subject.rating)
      end
    end

    context 'invalid parameters' do
      let(:invalid_title) { build(:post, title: nil) }
			let(:invalid_rating) { build(:post, rating: nil) }

      it 'returns a validation error when the title is invalid' do
        result = PostsRepository.create(title: invalid_title.title, rating: invalid_title.rating)
        expect(result.failure?).to eq(true)
        expect(result.failure).to be_a(Dry::Validation::Result)
      end

			it 'returns a validation error when the rating is invalid' do
        result = PostsRepository.create(title: invalid_rating.title, rating: invalid_rating.rating)
        expect(result.failure?).to eq(true)
        expect(result.failure).to be_a(Dry::Validation::Result)
      end
    end
  end
end

The above tests that I have written test the #create method of the PostsRepository class for both valid and invalid parameters.

  • In the valid parameter test, I simply use build(:post) to create our default object to test against.
  • With the invalid parameter tests, I pass extra invalid parameters to the #create method and check for a Failure monad of type Dry::Validation::Result.

You could go into more detail here and check the different varieties of invalid parameters, but I will leave that for another time.

As for the rest of the tests, I checked the error routes by stubbing out methods on Post and PostContract:

The final tests are follows:

require 'rails_helper'
require 'posts_repository'

RSpec.describe PostsRepository do
  describe '#create' do
    context 'valid parameters' do
      let(:subject) { build(:post) }

      it 'returns a post' do
        result = PostsRepository.create(title: subject.title, rating: subject.rating)
        expect(result.success?).to eq(true)

        post = result.value!
        expect(post).to be_a(Post)
        expect(post.title).to eq(subject.title)
        expect(post.rating).to eq(subject.rating)
      end
    end

    context 'invalid parameters' do
      let(:invalid_title) { build(:post, title: nil) }
      let(:invalid_rating) { build(:post, rating: nil) }

      it 'returns a validation error when the title is invalid' do
        result = PostsRepository.create(title: invalid_title.title, rating: invalid_title.rating)
        expect(result.failure?).to eq(true)
        expect(result.failure).to be_a(Dry::Validation::Result)
      end

      it 'returns a validation error when the rating is invalid' do
        result = PostsRepository.create(title: invalid_rating.title, rating: invalid_rating.rating)
        expect(result.failure?).to eq(true)
        expect(result.failure).to be_a(Dry::Validation::Result)
      end
    end

    context 'error responses' do
      let(:subject) { build(:post) }

      it 'should return a Failure monad for an ActiveRecordError' do
        PostContract.stub(:new).and_raise(ActiveRecord::ActiveRecordError)

        result = PostsRepository.create(title: subject.title, rating: subject.rating)
        expect(result.failure?).to eq(true)
        expect(result.failure).to be_a(ActiveRecord::ActiveRecordError)
      end

      it 'should return a Failure monad for a StandardError' do
        PostContract.stub(:new).and_raise(StandardError)

        result = PostsRepository.create(title: subject.title, rating: subject.rating)
        expect(result.failure?).to eq(true)
        expect(result.failure).to be_a(StandardError)
      end
    end
  end

  describe '#find_all' do
    context 'successful responses' do
      let(:post_one) { create(:post) }
      let(:post_two) { create(:post) }

      it 'should return as a valid array of posts' do
        result = PostsRepository.find_all
        expect(result.success?).to eq(true)

        posts = result.value!
        expect(posts).to eq([post_one, post_two])
      end
    end

    context 'error responses' do
      it 'should return a Failure monad for an ActiveRecordError' do
        Post.stub(:all).and_raise(ActiveRecord::ActiveRecordError.new)

        result = PostsRepository.find_all
        expect(result.failure?).to eq(true)
        expect(result.failure).to be_a(ActiveRecord::ActiveRecordError)
      end

      it 'should return a Failure monad for a StandardError' do
        Post.stub(:all).and_raise(StandardError.new)

        result = PostsRepository.find_all
        expect(result.failure?).to eq(true)
        expect(result.failure).to be_a(StandardError)
      end
    end
  end

  describe '#find_by_id' do
    let(:subject) { create(:post) }

    context 'valid parameters' do
      it 'returns a valid post' do
        result = PostsRepository.find_by_id(id: subject.id)
        expect(result.success?).to eq(true)

        post = result.value!
        expect(post).to be_a(Post)
        expect(post.title).to eq(subject.title)
        expect(post.rating).to eq(subject.rating)
      end
    end

    context 'error responses' do
      it 'should return a Failure monad for an RecordNotFound' do
        result = PostsRepository.find_by_id(id: nil)
        expect(result.failure?).to eq(true)
        expect(result.failure).to be_a(ActiveRecord::ActiveRecordError)
      end

      it 'should return a Failure monad for an ActiveRecordError' do
        Post.stub(:find_by!).and_raise(ActiveRecord::ActiveRecordError)

        result = PostsRepository.find_by_id(id: subject.id)
        expect(result.failure?).to eq(true)
        expect(result.failure).to be_a(ActiveRecord::ActiveRecordError)
      end

      it 'should return a Failure monad for a StandardError' do
        Post.stub(:find_by!).and_raise(StandardError)

        result = PostsRepository.find_by_id(id: subject.id)
        expect(result.failure?).to eq(true)
        expect(result.failure).to be_a(StandardError)
      end
    end
  end
end

If you want the validate the difference between build and create, you can swap out create for build on the helper methods testing the find_by_id method and you'll notice that they will fail because the subject model was not saved prior to the test.

Summary

Today's post demonstrated how to use Factory Bot when testing classes that rely on database models.

The factories allow a quick way to build out valid models to use for testing and the create helper is a great tool for writing tests that check against finding particular values.

This essentially concludes the little three-part series that I have been working on that explored FactoryBot, dry-validation and the repository pattern.

Moving on, I believe the next topics I have prepared to write on are Sorbet for type checking!

Resources and further reading

Photo credit: nickhh