Reputation: 239
I am trying to build an RSpec test spec for my model: Logo that will ensure that only a singular record can be saved to the database. When I utilize the .build
method for the second call to build a Logo, my test fails because FactoryBot is able to build out a Logo.
However, if I use the .create
method for the second Logo entry in FactoryBot I receive an error for the test because my model raises an error, as instructed, based upon my model's method for the :only_one_row
method.
How can I make this work using RSpec and FactoryBot?
Here is the code I have tried, unsuccessfully:
# app/models/logo.rb
class Logo < ApplicationRecord
before_create :only_one_row
private
def only_one_row
raise "You can only have one logo file for this website application" if Logo.count > 0
end
end
# spec/factories/logos.rb
FactoryBot.define do
factory :logo do
image { File.open(File.join(Rails.root, 'spec', 'fixtures', 'example_image.jpg')) }
end
end
# spec/logo_spec.rb
require 'rails_helper'
RSpec.describe Logo, type: :model do
it 'can be created' do
example_logo = FactoryBot.create(:logo)
expect(example_logo).to be_valid
end
it 'can not have more than one record' do
# Ensure there are no logo records in the database before this test is run.
Logo.destroy_all
example_logo_one = FactoryBot.create(:logo)
# This is where the trouble lies...
# If I go with .create method I error with the raised error defined in my model file...
example_logo_two = FactoryBot.create(:logo)
# ... if I go with the .build method I receive an error as the .build method succeeds
# example_logo_two = FactoryBot.build(:logo)
expect(example_logo_two).to_not be_valid
end
end
Upvotes: 0
Views: 1647
Reputation: 1968
Two things here:
If your whole application only ever allows a single logo at all (and not, say, a single logo per company, per user or whatever), then I don't think there's a reason to put it in the database. Instead, simply put it in the filesystem and be done with it.
If there is a good reason to have it in the database despite my previous comment and you really want to make sure that there's only ever one logo, I would very much recommend to set this constraint on a database level. The two ways that come to mind is to revoke INSERT
privileges for the relevant table or to define a trigger that prevents INSERT
queries if the table already has a record.
This approach is critical because it's easily forgotten that 1) validations can be purposefully or accidentally circumvented (save(validate: false)
, update_column
etc.) and 2) the database can be accessed by clients other than your app (such as another app, the database's own console tool etc.). If you want to ensure data integrity, you have to do such elemental things on a database level.
Upvotes: 1
Reputation: 165586
When I utilize the .build method for the second call to build a Logo, my test fails because FactoryBot is able to build out a Logo.
That is correct, build
does not save the object.
However, if I use the .create method for the second Logo entry in FactoryBot I receive an error for the test because my model raises an error, as instructed, based upon my model's method for the :only_one_row method.
Catch the exception with an expect
block and the raise_error
matcher.
context 'with one Logo already saved' do
let!(:logo) { create(:logo) }
it 'will not allow another' do
expect {
create(:logo)
}.to raise_error("You can only have one logo file for this website application")
end
end
Note this must hard code the exception message into the test. If the message changes, the test will fail. You could test for RuntimeError, but any RuntimeError would pass the test.
To avoid this, create a subclass of RuntimeError, raise that, and test for that specific exception.
class Logo < ApplicationRecord
...
def only_one_row
raise OnlyOneError if Logo.count > 0
end
class OnlyOneError < RuntimeError
MESSAGE = "You can only have one logo file for this website application".freeze
def initialize(msg = MESSAGE)
super
end
end
end
Then you can test for that exception.
expect {
create(:logo)
}.to raise_error(Logo::OnlyOneError)
Note that Logo.destroy_all
should be unnecessary if you have your tests and test database set up correct. Each test example should start with a clean, empty database.
Upvotes: 2
Reputation: 7532
Your validation here is implemented as a hook, not a validation, which is why the be_valid
call will never fail. I want to note, there's no real issue here from a logical perspective -- a hard exception as a sanity check seems acceptable in this situation, since it shouldn't be something the app is trying to do. You could even re-write your test to test for it explicitly:
it 'can not have more than one record' do
# Ensure there are no logo records in the database before this test is run.
Logo.destroy_all
example_logo_one = FactoryBot.create(:logo)
expect { FactoryBot.create(:logo) }.to raise_error(RuntimeError)
end
But, if there's a possibility the app might try it and you want a better user experience, you can build this as a validation. The tricky part there is that the validation looks different for an unsaved Logo (we need to make sure there are no other saved Logos, period) versus an existing one (we just need to validate that we're the only one). We can make it one single check just by making sure that there are no Logos out there that aren't this one:
class Logo < ApplicationRecord
validate do |logo|
if Logo.first && Logo.first != logo
logo.errors.add(:base, "You can only have one logo file for this website application")
end
end
end
This validation will allow the first logo to save, but should immediately know that the second logo is invalid, passing your original spec.
Upvotes: 2