Today we’re going to build out the data model for managing recipes. In Rails, models are very important so we’re going to do a deep dive on just that portion of the feature. We’ll also write automated tests to help us better understand the code that we’re writing.
Goal for this Tutorial:
- Create a new model for Recipe.
- Write specs to ensure we’re modeling our data correctly.
- Understand model validations.
One of the main features of our application is that you can create recipes and eventually be able to generate meal plans from it. Before we can have meal plans we’re going to need Recipes. We’ll tackle most of how a Recipe will work today.
Most of what we do today will be in code, but we’re going to need to run some things from the command line also, so we’re going to keep a bash prompt opened up to our rails container:
docker-compose exec app bash
Modeling a Recipe
Our Recipe model is going to start out very simple, but we will likely make it more complicated in the future. We’re going to have our recipes’ have a unique name and a description. The first thing that we need to do is create our database table using the migration generator:
$ rails generate migration create_recipes
We’re only creating the file with the timestamp using that generator, we’ll write the contents ourselves. Your file name will be slightly different.
db/migrate/20161119131322_create_recipes.rb
class CreateRecipes < ActiveRecord::Migration[5.0]
def change
create_table :recipes do |t|
t.string :name, null: false
t.text :description, null: false
t.belongs_to :user, null: false, foreign_key: true
t.timestamps null: false
end
add_index :recipes, [:user_id, :name]
end
end
That will set up the fields that we want and ensure that a single user doesn’t create the same recipe over and over again. Don’t forget to run this migration:
$ rake db:migrate
Introducing Test Driven Development (TDD)
Since Recipe is going to be one of the main models that we use in our application we want to make sure that we’re setting it up properly. In Ruby, writing automated tests is a common practice, as it should be in most settings. Testing is even more important in Ruby since we don’t have a compiler to check our work as we go. We’re going to test drive our Recipe model so that we know it works the way we expect it to.
When we generated this application we skipped test, but we’re going to set it up now to use minitest.
Setting up Minitest & FactoryGirl
We’ll use the minutest-rails
gem and the generators and rake tasks that it provides to get our test suite up and running. This gem needs to be added to the development
and test
groups, because it’s not necessary when we put our application into production. We’ll also pull in factory_girl_rails
to help us generate data in our tests.
Gemfile
# Other gems
group :development, :test do
gem 'byebug', platform: :mri
gem 'minitest-rails'
gem 'factory_girl_rails'
end
# Other gems
From there we’ll, need to rebuild our image and restart our container:
$ docker-compose stop app && docker-compose build && docker-compose up -d app
From within the container (docker-compose exec back in), we’ll run the generate to create our test helper:
$ rails g minitest:install
We’ll modify the test helper to not load fixtures since we won’t be using them:
test/test_helper.rb
ENV["RAILS_ENV"] = "test"
require File.expand_path("../../config/environment", __FILE__)
require "rails/test_help"
require "minitest/rails"
# To add Capybara feature tests add `gem "minitest-rails-capybara"`
# to the test group in the Gemfile and uncomment the following:
# require "minitest/rails/capybara"
# Uncomment for awesome colorful output
# require "minitest/pride"
class ActiveSupport::TestCase
# Add more helper methods to be used by all tests here...
include FactoryGirl::Syntax::Methods
end
We’ll make some modifications to how generators work so that rails knows to create a minutest-spec file if we generate something that would create a test in the future. Here’s the complete config that our application has thus far:
config/application.rb
require_relative 'boot'
require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_view/railtie"
require "action_cable/engine"
require "sprockets/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module MealPlan
class Application < Rails::Application
# Settings in config/environments/* take precedence over those specified here.
# Application configuration should go into files in config/initializers
# -- all .rb files in that directory are automatically loaded.
# Configure Generators
config.generators do |g|
g.test_framework :minitest, spec: true
end
end
end
Now we’ll set up our test directory to have some subdirectories.
$ mkdir -p test/{factories,models}
$ touch test/factories/.gitkeep
Finally, we will create our first spec to make sure that we set everything up properly. It’s important that we name our file properly, with a “_test” at the end. We’re looking to make a test that fails with the error we expect.
test/models/recipe_test.rb
require "test_helper"
describe Recipe do
it "will fail" do
assert true == false
end
end
Now we can run this test:
$ rails test
And we should see the following failure:
Test Driving our Recipe Model
Now that we have our testing environment it’s time to actually do the testing. The idea with Test Driven Development is that we’ll write our tests first, showing how we want our finished code to behave. Running the test it will fail, and we will incrementally make the test pass by reading the errors that are given and making those errors go away. When you’re first starting out with a language I think TDD is great for helping you get to know the error messages.
Our tests will revolve around making sure that our recipe validations work properly. We’ll write all of the specs up front, but when we’ll only worry about fixing them one at a time.
test/models/recipe_test.rb
require "test_helper"
describe Recipe do
describe "validity" do
let(:recipe) { Recipe.new }
before do
recipe.valid?
end
it "requires a user" do
recipe.errors[:user].must_include "can't be blank"
end
it "requires a description" do
recipe.errors[:description].must_include "can't be blank"
end
it "requires a name" do
recipe.errors[:name].must_include "can't be blank"
end
it "requires the name be unique for the same user" do
existing_recipe = FactoryGirl.create(:recipe)
recipe.name = existing_recipe.name
recipe.user = existing_recipe.user
recipe.valid?
recipe.errors[:name].must_include "has already been taken"
end
it "does not require the name be unique with different users" do
existing_recipe = FactoryGirl.create(:recipe)
recipe.name = existing_recipe.name
recipe.valid?
recipe.errors[:name].wont_include "has already been taken"
end
end
end
Before we can run these tests we’ll need to create our factories. We’ll use the factory_girl:model
generator to create the files and then fill out the values that we need.
$ rails g factory_girl:model User
$ rails g factory_girl:model Recipe
Now let’s fill in the information that would work to create a valid User:
test/factories/users.rb
FactoryGirl.define do
factory :user do
sequence(:email) { |n| "example_user_#{n}@example.com" }
password "password"
end
end
We didn’t create any validations on our User, but the module that we included from Clearance did. One of those validations is that the email must be unique. Because of this we use a sequence to make sure that every time we use FactoryGirl to create a User it will have a different email address.
Next we will need to create our recipe
factory.
test/factories/recipes.rb
FactoryGirl.define do
factory :recipe do
name "Mom's Spaghetti"
description "The best pasta in the world."
association(:user)
end
end
The only thing that’s really notable here is that we use the association(:user)
method to make sure that every recipe we create has an associated User
object. We can specify a different user when we use the factory, but by default it will create a new one.
Now we should be able to run our tests:
Getting the Tests to Pass
A few of our tests fail because of an undefined method user=
, since this is failing before we even hit the assertion we should fix that first. The reason for this failure is because the Recipe model doesn’t actually know that is associated to a User
yet. It’ll only take one line to fix this error.
app/models/recipe.rb
class Recipe < ApplicationRecord
belongs_to :user
end
If we rerun the tests now we won’t see the undefined method error any more. Next, we’re going to fix the errors from the missing “can’t be blank” errors. These message come from the “presence” validation that we haven’t set up yet.
app/models/recipe.rb
class Recipe < ApplicationRecord
belongs_to :user
validates :name, presence: true
validates :description, presence: true
validates :user, presence: true
end
Rerunning the tests now will show that we only have one test failing. We’re missing a “uniqueness” validation on the name attribute. Let’s add that now and see what happens:
app/models/recipe.rb
class Recipe < ApplicationRecord
belongs_to :user
validates :name, presence: true, uniqueness: true
validates :description, presence: true
validates :user, presence: true
end
Now we’re getting a different failure. The validation that we currently have for uniqueness ensures that there will only ever be one recipe in the database with a specific name, but that’s not exactly what we want. We want to make sure that each User can only have one recipe of a specific name, but multiple users can have recipes that share a common name. We can accomplish this by tweaking our uniqueness validation a little to use a scope.
app/models/recipe.rb
class Recipe < ApplicationRecord
belongs_to :user
validates :name, presence: true, uniqueness: { scope: :user_id }
validates :description, presence: true
validates :user, presence: true
end
Rerunning the tests show that they’re all passing! Using a hash instead of a true value allowed us to be a little more specific and say that the uniqueness only matters if the recipe rows have the same “user_id” value.
Recap
This was a feature packed portion even though we didn’t completely finish a feature. Models and validations are really important when working with Rails so it’s really good to build a good understanding of them. We also went through a whirlwind tour of testing. Automated testing is one of my favorite tools to use in programming, both for learning and also for ensuring that I don’t write bugs as I continue development on a project. Since the project is set up for testing now it will be a lot easier for us to use them moving forward.