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
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:
build(:post)where the model is not saved.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
#createmethod and check for aFailuremonad of typeDry::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