Today we’re going to start developing our main feature: meal planning. We’ll use test driven development to build out how a meal plan should work.
Goal for this Tutorial:
- Test Drive the behavior of a meal plan.
- Database modeling a meal plan.
Meal planning is the bread and butter of our application so we’re going to spend quite a bit of time thinking through how we’re going to model this so that it makes sense.
We need to find a way to structure a meal plan in our database and create a good interface for us to use a meal plan to build out a sane list of recipes to cook.
Renaming our Application
Before we get too far we’re going to need to make a change to our application config. When we generated the rails application we used the name of meal_plan
which created a module for us to house our application configuration. This module is named MealPlan
and because of that we can’t create a model named MealPlan
. We’re going to change this module name to be MealPlanner
. Here’s the new application.rb
:
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 MealPlanner
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.
config.generators do |g|
g.test_framework :minitest, spec: true
end
end
end
Modeling a Meal Plan
Up until now we haven’t really thought about what a meal plan consisted of, but in order for us to model it in our database we need to lay out what it means to be a meal plan. Here are the requirements that I can think of off hand:
- Start Date
- End Date
- List of Recipes
- User
That seems pretty simple, but it’s going to require us to create a few different database tables. The meal_plans
table isn’t actually going to have much on it besides the start_date
and end_date
. The more involve model will actually be the Meal
model which will act to join together a Recipe
and a MealPlan
. Our Meal
model will need to have the following:
- date
- recipe_id
- meal_plan_id
We need the date so that we know when we’re planning to cook the recipe. The Meal
will hold onto both the meal_plan_id
and recipe_id
because recipes will be on numerous meal plans. We could get around this by having the MealPlan
model have seven different recipe columns (monday_recipe
, tuesday_recipe
, etc), but this is less than ideal because what do we do when we also want to plan out lunches instead of just dinner? By having a separate model to associate recipes and meal plans we can be more explicit about what a meal is and that will allow us to be flexible moving forward.
Let’s create these models now:
$ rails g migration create_meal_plans
invoke active_record
create db/migrate/20161126154052_create_meal_plans.rb
Modifying the migration that was created we’ll add our date fields.
db/migrate/20161126154052_create_meal_plans.rb
class CreateMealPlans < ActiveRecord::Migration[5.0]
def change
create_table :meal_plans do |t|
t.date :start_date, null: false
t.date :end_date, null: false
t.references :user, null: false, foreign_key: true
t.timestamps null: false
end
end
end
Now we can generate our meals
table:
$ rails g migration create_meals
invoke active_record
create db/migrate/20161126154355_create_meals.rb
db/migrate/20161126154355_create_meals.rb
class CreateMeals < ActiveRecord::Migration[5.0]
def change
create_table :meals do |t|
t.date :date, null: false
t.references :meal_plan, null: false, foreign_key: true
t.references :recipe, null: false, foreign_key: true
t.timestamps null: false
end
end
end
Since we have two new models we will go ahead and also create our factories now, leaving the implementations empty for a second:
$ rails g factory_girl:model MealPlan
create test/factories/meal_plans.rb
$ rails g factory_girl:model Meal
create test/factories/meals.rb
Finally, we’ll run our migrations and get ready to test drive these new models:
rake db:migrate
Validating the MealPlan and Meal Models
The initial set up of our MealPlan
model will be pretty simple so we’re going to quickly write the tests for that.
test/model/meal_plan_test.rb
require "test_helper"
describe MealPlan do
describe "validity" do
let(:meal_plan) { MealPlan.new }
before do
meal_plan.valid?
end
it "requires a start_date" do
meal_plan.errors[:start_date].must_include "can't be blank"
end
it "requires an end_date" do
meal_plan.errors[:end_date].must_include "can't be blank"
end
it "requires a user" do
meal_plan.errors[:user].must_include "can't be blank"
end
end
end
We can implement this right away based on what we’ve learned in a previous tutorial.
app/model/meal_plan.rb
class MealPlan < ApplicationRecord
belongs_to :user
validates :start_date, presence: true
validates :end_date, presence: true
validates :user, presence: true
end
Moving onto Meal
we’ll have similarly simple tests to write:
test/models/meal_test.rb
require "test_helper"
describe Meal do
describe "validity" do
let(:meal) { Meal.new }
before do
meal.valid?
end
it "requires a date" do
meal.errors[:date].must_include "can't be blank"
end
it "requires a meal_plan" do
meal.errors[:meal_plan].must_include "can't be blank"
end
it "requires a recipe" do
meal.errors[:recipe].must_include "can't be blank"
end
end
end
The initial implementation for Meal
is also simple:
app/model/meal.rb
class Meal < ApplicationRecord
belongs_to :meal_plan
belongs_to :recipe
validates :date, presence: true
validates :meal_plan, presence: true
validates :recipe, presence: true
end
Generating a Meal Plan
While we will definitely allow users to create meal plans customizing all of the recipes that are used, but we’re really wanting meal planning to take little thought so we’ll generate the initial list of recipes ourselves. Thankfully we can write tests to ensure that this works. Let’s open the test file for MealPlan
back up and go through what we would like it to look like to generate a meal plan.
test/models/meal_plan_test.rb
require "test_helper"
describe MealPlan do
# Validation tests ...
describe "generating a weekly plan" do
let(:meal_plan) { build(:meal_plan) }
before do
7.times do
create(:recipe, user: meal_plan.user)
end
end
it "populates a meal for each date between start and end" do
meal_plan.meals.size.must_equal 0
meal_plan.build_meals
meal_plan.meals.size.must_equal 7
end
end
end
Running the test right now will show you that we haven’t implemented our factory yet, so let’s do that now:
test/factories/meal_plans.rb
FactoryGirl.define do
factory :meal_plan do
start_date { Date.today }
end_date { 6.days.from_now }
association(:user)
end
end
Running the tests now (rake tests
), we get the error that build_meals
doesn’t exist, which is just what we needed. Let’s follow strict TDD and add an empty method.
app/models/meal_plan.rb
def build_meals
end
With that we get an expected error saying that we expected 7 but got 0. Now we get to actually implement this method. We need to create a meal for each date so we can use a ruby range for that.
app/models/meal_plan.rb
def build_meals
user_recipe_ids = user.recipes.pluck(:id)
(start_date..end_date).each do |date|
available_recipe_ids = user_recipe_ids - meals.map(&:recipe_id)
meals.build(date: date, recipe_id: available_recipe_ids.sample)
end
end
This will get our test passing, but just looking at this it won’t handle the case where a user doesn’t have enough recipes to fill out a full week. Let’s write some tests to handle that and tweak this to work.
test/models/meal_plan_test.rb
describe "generating a weekly plan" do
let(:meal_plan) { build(:meal_plan) }
before do
7.times do
create(:recipe, user: meal_plan.user)
end
end
it "populates a meal for each date between start and end" do
meal_plan.meals.size.must_equal 0
meal_plan.build_meals
meal_plan.meals.size.must_equal 7
end
it "builds valid meals" do
meal_plan.build_meals
meal_plan.meals.all?(&:valid?).must_equal true
end
it "uses unique recipes" do
meal_plan.build_meals
uniq_ids = meal_plan.meals.map(&:recipe_id).uniq
uniq_ids.size.must_equal 7
end
describe "with more days than recipes" do
let(:meal_plan) { build(:meal_plan, end_date: 8.days.from_now) }
it "build valid meals" do
meal_plan.build_meals
meal_plan.meals.all?(&:valid?).must_equal true
end
it "reuses recipes where necessary" do
meal_plan.build_meals
uniq_ids = meal_plan.meals.map(&:recipe_id).uniq
uniq_ids.size.must_equal 7
end
end
end
Our extra test cases make this work by extending the length of the meal plan to have more days than recipes we created. These two tests will fail because we will attempt to build meals with nil
for the recipe_id. Here’s how we can fix this:
app/models/meal_plan.rb
def build_meals
user_recipe_ids = user.recipes.pluck(:id)
(start_date..end_date).each do |date|
unused_recipes = user_recipe_ids - meals.map(&:recipe_id)
available_recipes = unused_recipes.empty? ? user_recipe_ids : unused_recipes
meals.build(date: date, recipe_id: available_recipes.sample)
end
end
This change will change the recipe ids we pull from after we’ve used all of the unique ones, and will get our tests passing.
Recap
We’ve created a few more models that we’ll use to create a useful meal planning application and we wrote some logic to actually randomize a list of meals. In the next tutorial we’ll use this logic to build out a meal plan and pre-populate the user interface while still allowing the user to customize the recipes that are being used.