Building a Ruby on Rails (RoR) API Part II: Models and their tests

Exploring Rails API model creation and testing for an Agri-Food Traceability system: Models and Tests

  ·   10 min read

In my previous article I introduced the problem that motivated building an API using Ruby on Rails. For this article I’ll talk about testing and models.

Models and their tests

Having explained what the models should be, it’s time to work on building them. But as you might know a very good principle in coding is to have tests written before the actual code. And models are not the exception. Having the rules explained above, I can write the tests to avoid writing unnecesary code in the final model. I’ll tackle the simple models with the simple rules and make them fail to write the actual code.

The path to have the models covered is as follows:

  1. Create the model using Rails generator (by doing this, Rails will generate the files for model spec and the actual model file)
  2. Write model tests
  3. Run the model tests and check where they fail
  4. Write the model code to make the tests pass
  5. Improve and repeat

The Product Model

For simple models, the procedure it’s quite the same, I’ll just talk about this particular one.

Let’s create the model using Rails generator

1$ rails g model Product name image_url description show:boolean

That invokes active record and rspec to create the files and the migration for the DB, which has to be run.

Before running the migration it’s necessary to set the show boolean attribute to true as default in the respective migration file:

 1# /db/migrate/20170329223343_create_products.rb
 2class CreateProducts < ActiveRecord::Migration[5.1]
 3  def change
 4    create_table :products do |t|
 5      t.string :name
 6      t.boolean :show, default: true
 7
 8      t.timestamps
 9    end
10  end
11end

Actually, all the models will share the same line for the show boolean attribute. The reason is that no data will actually be deleted when requested, instead it will be set to false, in order to preserve referential integrity. Now the migration can be run.

1$ rake db:migrate

Now, thinking about what is really necessary, one can guess that the only needed attribute is the name of the product. It’s description and image_url might be omitted. From the DB schema it also has many Crops. So the test would look like this:

 1# /spec/models/product_spec.rb
 2require 'rails_helper'
 3
 4RSpec.describe Product, type: :model do
 5  # Ensure Product has column name present before saving
 6  it { should validate_presence_of(:name) }
 7
 8  # Ensure Product has many Crops
 9  it { should have_many(:crops) }
10end

Time to run the test.

1$ rspec

The test should fail. Time to fix it.

1# /app/models/product.rb
2class Product < ApplicationRecord
3  validates_presence_of :name
4  has_many :crops
5end

Let’s test again. But this time let’s test only this particular testing file:

1$ rspec spec/models/product_spec.rb

The test looks good. Time to dive into the more complex models. Remember, tests for simple models will look like this one.

The Package Model

Let’s cover the Package model, where a possible self dependency might occur.

As this model relies on the Crop and Route models, those should have been already written, as well as their respective tests.

Let’s generate the model:

1$ rails g model Package parent_id:integer crop:references route:references quantity:float show:boolean qrhash

Before running the migration, some changes have to be done to the file:

 1# /db/migrate/20170329223343_create_packages.rb
 2class CreatePackages < ActiveRecord::Migration[5.1]
 3  def change
 4    create_table :packages do |t|
 5      t.integer :parent_id, index: true
 6      t.references :crop, foreign_key: true
 7      t.references :route, foreign_key: true
 8      t.float :quantity
 9      t.boolean :show, default: true
10      t.qrhash, :string
11
12      t.timestamps
13    end
14
15    add_foreign_key :packages, :packages, column: :parent_id, primary_key: :id
16
17  end
18end

The changes here include to add the index for the parent_id, set the default value for the show attribute, and add the foreign key for the parent pack. After running the migration, the test has to be written.

 1# /spec/models/package_spec.rb
 2require 'rails_helper'
 3
 4RSpec.describe Package, type: :model do
 5  # Ensure package has quantity
 6  it { should validate_presence_of(:quantity) }
 7
 8  # Ensures associations
 9  it { should belong_to(:parent) }
10  it { should have_many(:packages) }
11  it { should belong_to(:crop) }
12  it { should belong_to(:route) }
13end

Now, run that test:

1$ rspec spec/models/package_spec.rb

Now, fix and then run the test.

 1# /app/models/package.rb
 2class Package < ApplicationRecord
 3
 4  belongs_to :crop
 5  belongs_to :route
 6  belongs_to :parent, class_name: 'Package', optional: true
 7  has_many :packages, class_name: 'Package', foreign_key: :parent_id
 8
 9  validates_presence_of :quantity
10
11end
1$ rspec spec/models/package_spec.rb

Good! Test passing.

The User, Producer and Warehouse Models

Let’s cover inheritance association.

When I wrote this code the very first time, I didn’t think about the inheritance association, for me, the Producer and Warehouse model were just similar but independent models, and I kept coding. Then I reached a point where I had to ensure login authentication and I got realized that I was going to have problems. But I managed to refactor my code. If you are curious about it, check the changes in the repository. But here, and for the sake of the article, I’ll tell how it should have be done since the beginning.

First, the User model has to be created:

1$ rails g model User username password email first_name last_name type show:boolean

Let’s improve the migration to get the desired effect on the DB.

 1# /db/migrate/20170428041558_create_users.rb
 2class CreateUsers < ActiveRecord::Migration[5.1]
 3  def change
 4    create_table :users do |t|
 5      t.string :username, null: false
 6      t.string :password, null: false
 7      t.string :email, null: false
 8      t.string :first_name
 9      t.string :last_name
10      t.string :type
11      t.boolean :show, default: true
12
13      t.timestamps
14    end
15
16    add_index :users, :username, unique: true
17    add_index :users, :email, unique: true
18  end
19end

Here, I’m setting all the basic fields a user should have, no matter if it’s a Producer or a Warehouse. Now a word of warning: I’ve already said I didn’t think well about this model when I wrote it first, so if you check the migration file at GitHub you will notice I’ve defined two methods: up and down. As you can see there, I had to drop my producers and warehouses tables, but in order to preserve the integrity when migrating and rolling back, I had to define how the forward and reverse process had to be done.

It is supposed this User model will inherit to both Producer and Warehouse, so I’ll proceed to explain the following three test files and their respective model files.

1# /spec/models/user_spec.rb
2require 'rails_helper'
3
4RSpec.describe User, type: :model do
5  # Ensure User has all fields
6  it {should validate_presence_of(:username)}
7  it {should validate_presence_of(:password)}
8  it {should validate_presence_of(:email)}
9end
 1# /spec/models/producer_spec.rb
 2require 'rails_helper'
 3
 4RSpec.describe Producer, type: :model do
 5  # Ensure Producer has first_name last_name username and password
 6  it { should validate_presence_of(:first_name) }
 7  it { should validate_presence_of(:last_name) }
 8  it { should validate_presence_of(:username) }
 9  it { should validate_presence_of(:password) }
10
11  # Ensure a Producer belongs to a Place and has many Crops
12  it { should have_many(:places) }
13  it { should have_many(:crops) }
14end
 1# /spec/models/warehouse_spec.rb
 2require 'rails_helper'
 3
 4RSpec.describe Warehouse, type: :model do
 5  # Ensure Warehouse has name username and password
 6  it { should validate_presence_of(:name) }
 7  it { should validate_presence_of(:username) }
 8  it { should validate_presence_of(:password) }
 9  it { should validate_presence_of(:email) }
10
11  # Ensure a Warehouse belongs to a Place
12  it { should have_many(:places) }
13end

Again, test, fail, fix.

1# /app/models/user.rb
2class User < ApplicationRecord
3  include Localizable
4
5  validates_presence_of :username
6  validates_presence_of :password
7  validates_presence_of :email
8end

I bet you realized about that line saying include Localizable. It has to do with a relationship between users and their places, but I’ll come back to this when talking about the Place model. Let’ continue with the other two models.

1# /app/models/producer.rb
2class Producer < User
3  has_many :crops
4
5  validates_presence_of :first_name
6  validates_presence_of :last_name
7
8end
1# /app/models/warehouse.rb
2class Warehouse < User
3
4  alias_attribute :name, :first_name
5
6  validates_presence_of :name
7
8end

First thing to notice, it that both Producer, and Warehouse inherit directly from User. Second, only User deal with mandatory fields for both children, but each one of them deal with their own concerns: Producer has to ensure the first_name and last_name, while Warehouse, only with the name. But there is another catch, there is no name attribute defined in the User model, nor in the DB. The solution is to use the first_name via an alias. Now the tests pass.

The Place model and its implications

Finally the Place model that has the most complex behavior and thus the most complex configuration. When you understand how this does work on Rails, it is not that hard, but if you’re quite new to Rails as I’m, the thing can be a headache.

Let’s start with the migration for places. Again, to come up with the final version of places, I had to run three migrations. The first one was to create the fields, the second to add the show field that I missed in the first migration and the third one, the most interesting one, to add the polymorphic behavior. Here I’ll summarize them as if I ran that as a single migration file.

 1class CreatePlaces < ActiveRecord::Migration[5.1]
 2  def change
 3    create_table :places do |t|
 4      t.string :tag
 5      t.float :lat
 6      t.float :lon
 7      t.boolean :show, default: true
 8      t.references :localizable, polymorphic: true
 9
10      t.timestamps
11    end
12  end
13end

Here I’m using Rails polymorphic associations. A type of association where records from a table belongs to either of the models associated, in this case Producer or Warehouse.

Now you might thing why I’m doing this polymorphic association through Producer and Warehouse and not directly to the User model. The answer is that in my mind the model User doesn’t really exist, kind of an abstract class. But not completely abstract because I’ll need it to be fully instantiable for authentication purposes.

Hands on test.

 1# /spec/models/place_spec.rb
 2require 'rails_helper'
 3
 4RSpec.describe Place, type: :model do
 5  # Ensure Place has columns tag, lat and lon present before saving
 6  it { should validate_presence_of(:tag) }
 7  it { should validate_presence_of(:lat) }
 8  it { should validate_presence_of(:lon) }
 9
10  # Ensure has many Routes and belongs to Users
11  it { should belong_to(:localizable) }
12  it { should have_many(:origins) }
13  it { should have_many(:destinations) }
14end

Easy things: validate presence of fields. Not that easy thing: localizable? origins? destinations? First let’s run test, get failed, fix.

Let’s check what’s going on with the Place model.

 1# /app/models/place.rb
 2class Place < ApplicationRecord
 3  belongs_to :localizable, polymorphic: true
 4
 5  has_many :origins, class_name: 'Route', foreign_key: :origin_id
 6  has_many :destinations, class_name: 'Route', foreign_key: :destination_id
 7
 8  validates_presence_of :tag
 9  validates_presence_of :lat
10  validates_presence_of :lon
11
12end
  • localizable: here we set the polymorphic association to Users. Remember that line include Localizable at the User model. That’s how the association is defined, of course it’s not visible there but through a concern:
1# /app/models/concerns/localizable.rb
2 module Localizable
3  extend ActiveSupport::Concern
4
5  included do
6    has_many :places, -> {where(show: true)}, as: :localizable
7  end
8end
  • origins and destinations: a Place belongs to a Route, but there, a place can be either an origin or a destination. Let’s check what’s going on with the Route model:
1# /app/models/route.rb
2class Route < ApplicationRecord
3  belongs_to :origin, class_name: 'Place'
4  belongs_to :destination, class_name: 'Place'
5
6  has_many :route_logs, -> {where show: true}
7  has_many :packages
8end

That covers all the ‘complicated’ things with models. Now it’s time to think on tests for the actual requests.