Learning Rails – Modeling a Recipe and Testing (Part 3)

Tue Nov 22, 2016 - 1800 Words

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 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:

All tests failing

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.