Model Validation and using FactoryBot

Photo by Scott Webb on Unsplash

Model Validation and using FactoryBot

Part 10 of building a Rails 7 application

Maintaining the data integrity of the models I am creating can be done through the use of the built in validation tools provided by Rails. These validations will prevent records being saved if they fail the rules I define. However I believe these rules should also be further enforced by applying constraints and indexes at the database level as well.

So let's take a look at a couple examples of validations I want to apply.

class Brand < ApplicationRecord
  has_many :brand_aliases, dependent: :destroy
  has_associated_audits

  audited

  validates :name, presence: true, uniqueness: true
  validates :count, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
end

A Brand must have a name and that must not be a duplicate of any other Brand name. The count attribute must also be a number that is equal to or greater than zero, but it can be nil if there is no count.

class Product < ApplicationRecord
  has_many :product_duplicates, dependent: :destroy
  has_associated_audits

  audited

  validates :product_id, presence: true, uniqueness: true
  validates :buy_list_count, numericality: { greater_than_or_equal_to: 0, only_integer: true }, allow_nil: true
end

A Product must have a product_id and it must be unique within all other Products. The buy_list_count field must be an integer greater than zero if it is not nil.

To enforce these in the database as well I will need the following changes made to the schema:

class AddIntegrityConstraints < ActiveRecord::Migration[7.0]
  def change
    change_column_null :brands, :name, false
    change_column_null :products, :product_id, false
    ...
    add_check_constraint :products, 'buy_list_count >= 0', name: 'buy_list_count_check'
    ...
    add_index :brands, :name, unique: true
    add_index :products, :product_id, unique: true
    ...
  end
end

Now let's see how these work in practice. First try not setting a name for the brand.

 > b = Brand.new 
 => #<Brand:0x00007fe3ec2e6990 id: nil, name: nil, canonical: false, count: nil, created_at: nil, updated_at: nil> 
 > b.valid?
  Brand Exists? (2.5ms)  SELECT 1 AS one FROM "brands" WHERE "brands"."name" IS NULL LIMIT $1  [["LIMIT", 1]]
 => false
 > b.errors.full_messages
 => ["Name can't be blank"]

Then see what happens if attempting to use a Brand name that already exists.

 > b = Brand.new 
 => #<Brand:0x00007fe3ec48bae8 id: nil, name: nil, canonical: false, count: nil, created_at: nil, updated_at: nil>
 > b.name = 'Heinz'
 => "Heinz" 
 > b.valid?
  Brand Exists? (0.3ms)  SELECT 1 AS one FROM "brands" WHERE "brands"."name" = $1 LIMIT $2  [["name", "Heinz"], ["LIMIT", 1]]
 => false   
 > b.errors.full_messages
 => ["Name has already been taken"]

How about a Product with a negative buy_list_count:

 > p = Product.new
 > p.buy_list_count = -1
 => -1 
 > p.valid?
  Product Exists? (0.5ms)  SELECT 1 AS one FROM "products" WHERE "products"."product_id" IS NULL LIMIT $1  [["LIMIT", 1]]
 => false  
 > p.errors.full_messages
 => ["Buy list count must be greater than or equal to 0"]

The status enum on Task does not allow anything other than one of the values in the enumeration. As a reminder of what that enum looked like:

app\models\task.rb

class Task < ApplicationRecord
  audited

  enum status: {
    pending: 'pending',
    processing: 'processing',
    complete: 'complete',
    error: 'error'
  }
  ...
end

And what happens when I try some different values:

 > t = Task.new
 > t.status = 'processed'
activerecord-7.0.3/lib/active_record/enum.rb:157:in `assert_valid_value': 'processed' is not a valid status (ArgumentError)
> t.status = 'processing'
 => "processing"

In addition to validations there will be some manipulation I want to apply to some attributes prior to them being saved, for example I want certain attributes to only ever be stored as lower case.

Here is an example of one of these before validation callbacks.

class ItemSellPack < ApplicationRecord
  has_many :item_sell_pack_aliases, dependent: :destroy
  has_associated_audits

  audited

  before_validation :clean

  validates :name, presence: true, uniqueness: true

  protected

  def clean
    # Allowed characters are a-z and space
    assign_attributes(name: name&.downcase&.tr('^a-z ', ' ')&.squeeze(' ')&.strip)
  end
end

In this example the ItemSellPack name may only have the lowercase letters a-z or a space. All other extraneous whitespace is removed.

To help me test whether my validations and callbacks are working I plan on using a gem called FactoryBot. This allows me to easily build models in various states, simulating good and bad data integrity. The advantage of FactoryBot over fixtures is that I can avoid performing any database activity at all, thus improving the performance of my test suite. I can also easily name and reuse these models elsewhere in the test suite.

Installing FactoryBot requires adding the gem to the Gemfile. I am using factory_bot_rails

Gemfile

group :test do
  # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
  gem 'capybara'
  gem 'factory_bot_rails'
  gem 'selenium-webdriver'
  gem 'simplecov', require: false
  gem 'webdrivers'
end

Then add the FactoryBot methods to the test helper so they are available in the test cases.

test\test_helper.rb

class ActiveSupport::TestCase
  ...
  # Add more helper methods to be used by all tests here...
  include FactoryBot::Syntax::Methods
end

Now I can start defining the factories. Note that because alias is a ruby keyword it needs to be defined differently using add_attribute.

test\factories\item_sell_pack_alias.rb

FactoryBot.define do
  factory :item_sell_pack_alias do
    association :item_sell_pack
    add_attribute(:alias) { 'ctn' }
    confirmed { true }
  end
end

And finally I can write some test cases for my validation and clean up callbacks:

require 'test_helper'

class ItemSellPackTest < ActiveSupport::TestCase
  test 'name presence validation' do
    item_sell_pack = build(:item_sell_pack, name: nil, canonical: nil)
    assert_not(item_sell_pack.save, 'Saved the item sell pack without required attributes')
  end

  test 'name uniqueness validation' do
    # NOTE: there is an ItemSellPack with a name of 'carton' already in the fixtures
    item_sell_pack = build(:item_sell_pack, name: 'carton', canonical: nil)
    assert_not(item_sell_pack.save, 'Saved the item sell pack using a duplicate name')
  end

  test 'cleaning' do
    item_sell_pack = build(:item_sell_pack, name: " 5  Btl  \n")
    item_sell_pack.valid?
    assert_equal('btl', item_sell_pack.name, 'Item sell pack contains illegal whitespace')
  end
end

guides.rubyonrails.org/active_record_valida..

api.rubyonrails.org/classes/ActiveRecord/Co..

github.com/thoughtbot/factory_bot