Skip to main content

Command Palette

Search for a command to run...

Model Validation and using FactoryBot

Part 10 of building a Rails 7 application

Updated
5 min read
Model Validation and using FactoryBot
A

I am a web developer who has been in the industry since 1995. My current tech stack preference is Ruby on Rails with JavaScript. Originally from Australia but now living in Scotland.

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

https://guides.rubyonrails.org/active_record_validations.html

https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html

https://github.com/thoughtbot/factory_bot

Rails 7 Application

Part 10 of 32

In this series I'll be documenting my journey of building a brand new Rails 7 application. Please comment if you have suggestions on how I can improve any aspect, or provide alternatives.

Up next

User Interface - Navigation and Layout Part 1

Part 11 of building a Rails 7 application