#rails#rspec

Form object validations in Rails 4

Yuva's avatar

Yuva

Of late, at Codemancers, we are using form objects to decouple forms in views. This also helps in cleaning up how the data filled by end user is consumed and persisted in the backend. So, far the results have been good.

What are form objects

This blog post assumes that you are already familiar with form objects. Railscasts has a nice screencast about form objects. Do check it out if you haven't already.

Use case

Let's say that there is an organization and it has several employees. We're tasked to build a Rails app that provides an interface where an admin can select one or more employees and send them emails. A typical interface implementation might look like this:

Employee email form

After selecting employees, filling-in the subject and body, and upon clicking "Send", the backend should send emails to the selected employees. This is done by passing the array of the ids of employees, the subject and body to the backend. The POST parameters for that request look like this:

{
  "utf8"=>"✓",
  "email_form"=>{"employee_ids"=>[""], "subject"=>"", "body"=>""},
  "commit"=>"Send emails to employees"
}

Mass mailer form

We will create a EmployeesMassMailerForm form to encapsulate the validations and performing the actual action of sending email. This form should accept the params sent by the form, perform validations like checking whether all the employee ids belong to organization etc., and then send the emails.

class Organization < ActiveRecord::Base
  def get_employees(ids)
    employees.where(id: ids)
  end
end
 
class EmployeeMassMailerForm
  include ActiveModel::Model
 
  attr_accessor :organization, :employee_ids, :subject, :body
 
  validates :organization, :employee_ids, :subject, :body, presence: true
  validate  :employee_ids_should_belong_to_organization
 
  def perform
    return false unless valid?
 
    @employees = organization.get_employees(xemployee_ids)
    @employees.each { |e| schedule_email_for(e) }
    true
  end
 
  private
 
  def employee_ids_should_belong_to_organization
    if organization.get_employees(employee_ids).length != employee_ids.length
      errors.add(:employee_ids, :invalid)
    end
  end
 
  def schedule_email_for(e)
    Mailer.send_email(e, subject, body)
  end
end

With Rails 4, ActiveModel ships with Model module which helps in assigning attributes, just like how you can do with ActiveRecord class, along with helpers for validations. It is no longer necessary to use other libraries for form objects. Just include ActiveModel in a PORO class and you are good to go.

Testing using rspec and shoulda

All the form objects can be broken down into 2 main sections:

  1. Validations
  2. Performing actions
Testing validations

Adding validations on forms and models is pretty straight forward. Except for database-related validations like uniqueness, all the ActiveRecord validations can be used on form objects. These validations also make it easy to display validation errors in the view.

At Codemancers, we mostly use rspec and shoulda for testing. Validations on forms can be tested like this:

describe EmployeeMassMailerForm do
  describe 'Validations' do
    it { should validate_presence_of(:organization) }
    it { should validate_presence_of(:employee_ids) }
    it { should validate_presence_of(:subject)      }
    it { should validate_presence_of(:body)         }
 
    context 'when employee ids belong to organization' do
      it 'validates form successfully' do
        employee_ids = [1, 2]
        organization = mock_model(Organization, get_employees: employees_ids)
 
        form = described_class.new(organization: organization, subject: 'Test',
                                   employee_ids: employee_ids, body: 'Test')
        expect(form).to be_valid
      end
    end
 
    context 'when one or more employee ids donot belong organization' do
      it 'fails to validate the form' do
        organization = mock_model(Organization, get_employees: [])
 
        form = described_class.new(organization: organization, subject: 'Test',
                                   employee_ids: [1, 2, 3], body: 'Test')
        expect(form).to be_invalid
      end
    end
  end
end

You can notice here that while validating employee ids, we use stubs and mock models so that tests never hit database. Testing a form that has validations is a bit hard, because one has to heavily stub and mock models until form becomes valid. But testing an invalid form is easy and sometimes easy to maintain. Notice that we do not care what get_employees returns and that we hard coded it with an empty array whose length is 0. Always try to put as many validations as possible on form object, so that very less exceptions are raised while performing actions.

Testing actions performed by form

Once all the validations pass, the form object will go ahead and perform the action it is supposed to do. It can be anything from sending emails to persisting objects to database. Lets see how we can test the action perform from above form.

describe EmployeeMassMailerForm do
  describe '#perform' do
    let(:organization) do
      employees = [stub(email: 'a@b.com'), stub(email: 'b@c.com')]
      mock_model(Organization, get_employees: employees)
    end
 
    let(:form) do
      described_class.new(organization: organization, subject: 'Test',
                          employee_ids: [1, 2], body: 'Test')
    end
 
    before(:each) do
      described_class.any_instance.should_receive(:valid?).and_return(true)
      InvitesMailer.deliveries.clear
    end
 
    it 'sends emails to all employees' do
      form.perform
      expect(InvitesMailer.deliveries.length).to eq 2
    end
 
    it 'returns true' do
      expect(form.perform).to be_true
    end
  end
end

The trick here is to hard-code valid? to be true in before block. Since we have already tested validations, we can hard code the return value of valid? to be true. This saves a bunch of db calls and mocks.

I hope you enjoyed this article and if you want to keep updated with latest stuff we are building or blogging about, follow us on twitter&bsp;

@codemancershq