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:
- Create the model using Rails generator (by doing this, Rails will generate the files for model spec and the actual model file)
- Write model tests
- Run the model tests and check where they fail
- Write the model code to make the tests pass
- 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 Crop
s. 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 toUser
s. Remember that line includeLocalizable
at theUser
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
anddestinations
: aPlace
belongs to aRoute
, but there, a place can be either anorigin
or adestination
. Let’s check what’s going on with theRoute
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.