Back to home

Factory Bot With Rails main image

Factory Bot With Rails

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

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on workingoutloud.dev, Den Dribbles and LandPad .

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.