Testing nested resources and associations in Rails
One flat Recipe table was enough to learn folders and fixtures but real cookbooks have more than just a “recipe”, they list ingredients and steps under each recipe. That’s what you are going to add in this chapter!
You will add models for ingredients and steps, belongs_to / has_many associations, accepts_nested_attributes_for for nested fields on the recipe form, and associations inside fixtures so ingredients(:salt) already belongs to recipes(:pancakes) before you open a browser. Every line of code you add by hand follows the same TDD habit as Chapter 8: scenario comments in the test file, red, smallest app change, green.
If you are coming here chapter by chapter then you might have already seen nested ingredients and steps in Chapter 4 where you practiced nested drills on the reference clone app. Here, you implement the same set of features but in your own recipe app.
What you will do in this chapter #
- Review new child tables and columns of the recipe:
IngredientandStep. Then generate related models and migrate. - Add fixture files linked to a parent recipe for
IngredientandStep– associations in fixtures. - TDD ingredient validations, then step validations, in their own model test files.
- TDD: recipe show page lists ingredients and steps (
has_manyonRecipe, then the show template). - TDD: create a recipe with an ingredient and a step, then wire the nested form and strong params.
- Add integration tests to ensure invalid nested ingredient or step blocks create.
- Add integration test to check an ingredient and a step is added also during recipe edit.
- Optionally extend the existing
creates a recipesystem test with ingredient and step fields. - Commit after a green run.
Scenarios to automate #
These are the outcomes you will prove by the end of the chapter. Model tests cover validations in Ruby; integration tests cover what readers see on the recipe pages over HTTP and system tests cover happy paths in a smoke style checks.
| Scenario | Expected |
|---|---|
| Blank ingredient name | Ingredient with no name is invalid |
| Negative ingredient quantity | Ingredient with quantity: -1 is invalid |
| Valid ingredient fixture | ingredients(:salt) passes valid? |
| Blank step instruction | Step without instruction is invalid |
| Non-positive step position | Step with position <= 0 is invalid |
| Valid step fixture | steps(:preheat) passes valid? |
| Recipe show page | Show page for Pancakes lists salt (with quantity and unit) and the preheat step |
| Create with ingredient and step | New recipe with nested ingredient and step saves; show page lists both |
| Invalid ingredient on create | Nested invalid ingredient blocks save: 422, no new rows, ingredient error on the form |
| Invalid step on create | Nested invalid step blocks save: 422, no new rows, step error on the form |
| Edit adds ingredient and step | Editing pancakes adds nested ingredient and step rows; show lists both |
Associations refresher #
Picture a recipe for pizza. The dish has a name and maybe preparation time on the recipe card. Under that you list what goes in it: ingredients (flour, tomato sauce, mozzarella) and how to make it: steps (knead the dough, add toppings, bake). One dish, many ingredients, many steps.
In the database, Rails stores that as a parent row in recipes and child rows in ingredients and steps. Each child row stores a foreign key recipe_id pointing at its parent recipe. Your pizza’s flour row and “knead the dough” row both carry the same recipe_id as the pizza title row.
| Type | Model | Association | Meaning |
|---|---|---|---|
| Parent | Recipe |
has_many :ingredients and has_many :steps |
One recipe has many ingredient rows and many step rows |
| Child | Ingredient |
belongs_to :recipe |
Each ingredient row belongs to one recipe |
| Child | Step |
belongs_to :recipe |
Each step row belongs to one recipe |
The child tables and columns you will add #
The recipes table from Chapter 5 stays the parent while you add two child tables in this chapter. Each ingredient and step row stores which recipe it belongs to through recipe_id. Review the columns below before you run the model generators so the migrations match the Pizza recipe example from the start of this section.
ingredients #
| Column | What it stores |
|---|---|
recipe_id |
Foreign key to the parent recipe row |
name |
Ingredient name (e.g. Flour, Salt) |
quantity |
How much (optional decimal) |
unit |
cups, pinch, g, and similar (optional) |
created_at / updated_at |
Rails timestamps (set automatically) |
steps #
| Column | What it stores |
|---|---|
recipe_id |
Foreign key to the parent recipe row |
position |
Order in the instructions (1, 2, 3) |
instruction |
Instruction for the recipe |
created_at / updated_at |
Rails timestamps (set automatically) |
Generate models and associations #
Chapter 5 used bin/rails generate scaffold for Recipe because recipes are the main resource: you needed routes, a controller, views, and starter tests for list, show, new, edit, and destroy. But ingredients and steps are different, they belong to a recipe so they don’t need their own top-level pages. A scaffold would generate separate controllers, routes, and CRUD views for Ingredient and Step; you don’t need that as you are only building nested structure for these two new tables on Recipe.
That’s why this chapter uses bin/rails generate model for the child tables instead of a scaffold. You wire associations on Recipe, nest fields on the existing recipe form, and list children (associated records) on the recipe show page. This also matches the structure of a cookbook you normally see out in the wild.
First, you will run the generators to create files for models and migrations, then create new tables with db:migrate. The generator adds bare models and belongs_to :recipe on each child model because of recipe:references in the generator command. Do not paste any additional associations, validations, or nested attributes yet; you will add every custom validation with TDD in the sections below. In the command line, run the following commands:
bin/rails generate model Ingredient recipe:references name:string quantity:decimal unit:string
bin/rails generate model Step recipe:references position:integer instruction:text
bin/rails db:migrate
Confirm the schema file after migration #
Migrations should add the two tables you planned above. Open db/schema.rb and confirm recipe_id on both child tables:
# db/schema.rb
create_table "ingredients", force: :cascade do |t|
t.integer "recipe_id", null: false
t.string "name"
t.decimal "quantity"
t.string "unit"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["recipe_id"], name: "index_ingredients_on_recipe_id"
end
create_table "steps", force: :cascade do |t|
t.integer "recipe_id", null: false
t.integer "position"
t.text "instruction"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["recipe_id"], name: "index_steps_on_recipe_id"
end
add_foreign_key "ingredients", "recipes"
add_foreign_key "steps", "recipes"
Each child row’s recipe_id points at one parent recipe to establish associations.
Lastly, open app/models/ingredient.rb and app/models/step.rb and confirm belongs_to :recipe is already there from the generator. This connects Ingredient and Step to a Recipe record, you will add has_many on Recipe in later sections so associations work from both sides.
Fixtures for ingredients and steps #
Chapter 6 gave every test the same set of recipe records in the database using fixtures, you will do the same with ingredients and steps as well. They both need the same shared data, with each child row pointing at its parent recipe using a concept called “fixture association”. This section covers why that matters, add YAML files for their respective fixtures and show how you load them in tests.
Don’t rebuild child rows in every test #
Without fixtures, every test that needs salt on pancakes might start with the setup like this:
recipe = recipes(:pancakes)
Ingredient.create!(recipe: recipe, name: "Salt", quantity: 1, unit: "pinch")
Step.create!(recipe: recipe, position: 1, instruction: "Preheat the pan.")
This works once but it quickly gets tiring when several model and integration tests all need the same ingredient and step lines just to wire up a single ingredient or step to the recipe. Imagine the repetitive lines like when you need 5 ingredients or steps, for example. This type of manual setup for test data introduces duplication and makes tests less maintainable.
This is why you need fixture files for ingredients and steps, each linking them to a parent with recipe: pancakes in YAML. Tests can then load ingredients(:salt) and steps(:preheat) instead of rebuilding rows every single time in every single test that require them.
Add fixture files with parent labels #
You can reference the associated parent record using a parent fixture label (e.g. recipe: pancakes) instead of a numeric id like you do for database records (e.g. recipe_id: 1). When you reference the parent’s fixture label, Rails creates relevant records and sets recipe_id automatically while loading the test suite.
Replace the content inside fixture for ingredients at test/fixtures/ingredients.yml with the following:
# test/fixtures/ingredients.yml
flour:
recipe: pancakes
name: Flour
quantity: 2
unit: cups
salt:
recipe: pancakes
name: Salt
quantity: 1
unit: pinch
And replace the content inside test/fixtures/steps.yml for steps fixture with the following:
# test/fixtures/steps.yml
preheat:
recipe: pancakes
position: 1
instruction: Preheat the pan.
simmer:
recipe: lentil_soup
position: 1
instruction: Simmer lentils until tender.
This is what’s happening in the code above:
- Each fixture key (
salt,preheat, …) is a label you call in tests withingredients(:salt)orsteps(:preheat). recipe: pancakeswires the child row to the parent fixture without hard-codingrecipe_id.
Accessing fixtures in tests #
Now that you have added fixtures for both ingredients and steps, you can access them the same way you access recipes in tests.
This is how you can access individual record, you have already done this with recipe in previous chapters with recipes(:pancakes):
ingredients(:salt)
steps(:preheat)
After you add has_many on Recipe in a later section, you can also access associated records from either side:
recipes(:pancakes).ingredients
ingredients(:salt).recipe
You will use these calls in the model and integration tests in further sections below.
Ingredient validations: red then green #
You will use the same habit of using TDD for new child models to add validations as you did with Recipe model in Chapter 8: red, add code, then green.
What counts as working? #
| Scenario | You might say |
|---|---|
| Valid fixture row | ingredients(:salt) passes valid? after validations land |
| Blank name | Ingredient with no name fails valid? with an error on name |
| Negative quantity | Ingredient with quantity: -1 fails valid? with an error on quantity |
Add scenarios to the test file #
Create test/models/ingredient_test.rb and add scenarios to test.
# test/models/ingredient_test.rb
require "test_helper"
class IngredientTest < ActiveSupport::TestCase
# Actor: test suite loading fixtures
# Starting point: ingredients(:salt) from YAML
# Action: call valid?
# Expected outcome: valid
# test "is valid" do
# end
# Actor: anyone saving an ingredient
# Starting point: new ingredient with quantity but no name
# Action: call valid?
# Expected outcome: invalid; error on name
# test "rejects blank name" do
# end
# Actor: anyone saving an ingredient
# Starting point: new ingredient with name and negative quantity
# Action: call valid?
# Expected outcome: invalid; error on quantity
# test "rejects negative quantity" do
# end
end
Red: add tests #
Replace the test/models/ingredient_test.rb with the following:
# test/models/ingredient_test.rb
require "test_helper"
class IngredientTest < ActiveSupport::TestCase
test "is valid" do
assert ingredients(:salt).valid?
end
test "rejects blank name" do
ingredient = Ingredient.new(name: "", quantity: 1, unit: "cup")
assert_not ingredient.valid?
assert_includes ingredient.errors[:name], "can't be blank"
end
test "rejects negative quantity" do
ingredient = Ingredient.new(name: "Pepper", quantity: -1, unit: "g")
assert_not ingredient.valid?
assert_includes ingredient.errors[:quantity], "must be greater than 0"
end
end
This is what’s happening in the code above:
test "is valid"loadsingredients(:salt)from YAML and checks the fixture row passesvalid?once validations land.test "rejects blank name"builds a newIngredientin memory (no save), callsvalid?, and checks the error hash includes"can't be blank"on:name.test "rejects negative quantity"does the same forquantity: -1and expects"must be greater than 0"on:quantity.
Run:
bin/rails test test/models/ingredient_test.rb
You want 2 failures on the two rejection tests since you haven’t added validations for a blank ingredient name and a negative quantity in the Ingredient model. That is red.
F
Failure:
IngredientTest#test_rejects_negative_quantity [test/models/ingredient_test.rb:18]:
Expected [] to include "must be greater than 0".
F
Failure:
IngredientTest#test_rejects_blank_name [test/models/ingredient_test.rb:12]:
Expected [] to include "can't be blank".
is valid on ingredients(:salt) should stay green because you don’t have any validations yet.
Green: add validations on Ingredient #
Keep belongs_to :recipe from the generator as it is and add validations for name and quantity at app/models/ingredient.rb:
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
belongs_to :recipe
validates :name, presence: true
validates :quantity, numericality: { greater_than: 0 }, allow_nil: true
end
This is what’s happening in the code above:
validates :name, presence: trueis the smallest validation that makesrejects blank namepass.validates :quantity, numericality: { greater_than: 0 }, allow_nil: truerejects negative amounts but still allows a blank quantity on the form (allow_nil: true).
Run the ingredient model file again. You want 0 failures and 0 errors on all three tests.
bin/rails test test/models/ingredient_test.rb
Step validations: red then green #
Step gets the same TDD habit as Ingredient: one model file, red, then green.
What counts as working? #
| Scenario | You might say |
|---|---|
| Valid fixture row | steps(:preheat) passes valid? after validations land |
| Blank instruction | Step with no instruction fails valid? with an error on instruction |
| Non-positive position | Step with position <= 0 fails valid? with an error on position |
Add scenarios to the test file #
Create test/models/step_test.rb and add following scenarios to the file:
# test/models/step_test.rb
require "test_helper"
class StepTest < ActiveSupport::TestCase
# Actor: test suite loading fixtures
# Starting point: steps(:preheat) from YAML
# Action: call valid?
# Expected outcome: valid
# test "is valid" do
# end
# Actor: anyone saving a step
# Starting point: new step with position but no instruction
# Action: call valid?
# Expected outcome: invalid; error on instruction
# test "rejects blank instruction" do
# end
# Actor: anyone saving a step
# Starting point: new step with instruction but position 0
# Action: call valid?
# Expected outcome: invalid; error on position
# test "rejects non-positive position" do
# end
end
Red: add tests #
Replace the content in the test/models/step_test.rb with the following:
# test/models/step_test.rb
require "test_helper"
class StepTest < ActiveSupport::TestCase
test "is valid" do
assert steps(:preheat).valid?
end
test "rejects blank instruction" do
step = Step.new(position: 1, instruction: "")
assert_not step.valid?
assert_includes step.errors[:instruction], "can't be blank"
end
test "rejects non-positive position" do
step = Step.new(position: 0, instruction: "Mix gently.")
assert_not step.valid?
assert_includes step.errors[:position], "must be greater than 0"
end
end
This is what’s happening in the code above:
test "is valid"checks thesteps(:preheat)fixture row once step validations exist.test "rejects blank instruction"proves an empty instruction string fails validation.test "rejects non-positive position"provesposition: 0fails with an error on:position.
Run:
bin/rails test test/models/step_test.rb
You want 2 failures on the two rejection tests because you haven’t added validations yet. That is red.
F
Failure:
StepTest#test_rejects_non-positive_position [test/models/step_test.rb:18]:
Expected [] to include "must be greater than 0".
F
Failure:
StepTest#test_rejects_blank_instruction [test/models/step_test.rb:12]:
Expected [] to include "can't be blank".
is valid on steps(:preheat) should stay green because you haven’t added any validations to check against.
Green: add validations #
While keeping belongs_to :recipe from the generator intact, add validations for instruction and position at app/models/step.rb.
# app/models/step.rb
class Step < ApplicationRecord
belongs_to :recipe
validates :instruction, presence: true
validates :position, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
end
This is what’s happening in the code above:
validates :instruction, presence: truematches the blank-instruction model test.validates :position, numericality: { only_integer: true, greater_than: 0 }, allow_nil: truematches the non-positive position test and keeps position optional when blank.
allow_nil: true on position keeps the column optional on the form; when position is present it must be a positive integer.
Run the step model file again:
bin/rails test test/models/step_test.rb
You want 0 failures and 0 errors on all three tests.
Where nested validations belong (model vs integration) #
Chapter 8 already drew the line for a single Recipe model: validations and methods in Ruby, filters and forms over HTTP. This chapter adds children under a parent and the answer remains the same as with the single Recipe, with the only change in the model names (Ingredient/Step).
| Kind | Test type | Notes |
|---|---|---|
Child validations (name, quantity, instruction, position) |
Model | TDD on Ingredient and Step in their own files. |
| Invalid nested ingredient on create | Integration | One post with a valid recipe title proves invalid nested ingredient blocks save and the ingredient error appears on the form (not a recipe-field error). |
| Invalid nested step on create | Integration | Same for an invalid nested step row and a step error on the form. |
| Create/edit with valid nested fields | Integration | Prove in test/integration/ when the story is create or edit. |
| Recipe show lists ingredients and steps | Integration | Prove on the recipe show page with assert_match / assert_select on the HTML. |
Prove child validations in test/models/ using plain Ruby on Ingredient and Step: build a record, call valid?, check the errors. No browser, no HTTP, no response.body.
Reach for integration tests when the story is what someone sees or submits on a recipe page:
- Does the pancakes show page list Salt and Preheat the pan.?
- Does creating a recipe with a nested ingredient and step show both on the detail page?
- Does editing a recipe with new nested ingredient and step rows show both on the detail page?
- Does an invalid nested ingredient or step on create return 422 and re-render the new form?
The sections below prove those reader-facing flows over HTTP: show lists first, then create, invalid nested children on create, and edit adds an ingredient and a step.
Recipe show page lists ingredients and steps #
When you open a recipe, you also want to see what goes in it (ingredients) and how to make it (steps). You have already wired fixtures and given pancakes a salt ingredient and a preheat step. Next you need to prove those names appear on the show page.
What counts as working? #
| Flow | You might say |
|---|---|
| Show page lists ingredients | Open pancakes; Salt (1 pinch) appears under Ingredients |
| Show page lists steps | Open pancakes; Preheat the pan. appears under Steps |
Add scenarios to the test file #
# test/integration/recipes_integration_test.rb
# Actor: guest (HTTP request, no browser)
# Starting point: pancakes has salt and preheat in fixtures
# Action: open the pancakes show page
# Expected outcome: recipe title, ingredient name, and step instruction in the page
# test "shows ingredients and steps on the recipe page" do
# end
Red: add the integration test #
Add the following test to test/integration/recipes_integration_test.rb at the end of the file just below the existing tests:
# test/integration/recipes_integration_test.rb
test "shows ingredients and steps on the recipe page" do
get recipe_url(recipes(:pancakes))
assert_response :success
assert_match recipes(:pancakes).title, response.body
assert_select "h2", "Ingredients"
assert_match "Salt (1 pinch)", response.body
assert_match "Flour (2 cups)", response.body
assert_select "h2", "Steps"
assert_match steps(:preheat).instruction, response.body
end
This is what’s happening in the code above:
get recipe_url(recipes(:pancakes))requests the show page over HTTP (no browser).assert_select "h2", "Ingredients"and"Steps"prove those sections exist in the HTML.assert_match "Salt (1 pinch)"and"Flour (2 cups)"prove fixture ingredients render with quantity and unit.assert_match steps(:preheat).instructionproves the ordered step text appears on the page.
Run:
bin/rails test test/integration/recipes_integration_test.rb
You want an error first and also a failure.
Error is due to foreign_key constraint in the database migration that was added by the Rails automatically when you passed recipe: :references while generating child models. Due to the foreign_key constraint you can’t delete only the parent record without specifying what to do with child/associated records. The error will go away once you wire up the association in Recipe model and tell Rails to delete ingredients and steps along with the recipe you are trying to delete.
And the failure is because you haven’t wired up the show view to render list of ingredients and steps. Those are red.
E
Error:
RecipesIntegrationTest#test_destroys_a_recipe:
ActiveRecord::InvalidForeignKey: SQLite3::ConstraintException: FOREIGN KEY constraint failed
app/controllers/recipes_controller.rb:62:in 'RecipesController#destroy'
test/integration/recipes_integration_test.rb:68:in 'block (2 levels) in <class:RecipesIntegrationTest>'
test/integration/recipes_integration_test.rb:68:in 'block in <class:RecipesIntegrationTest>'
F
Failure:
RecipesIntegrationTest#test_shows_ingredients_and_steps_on_the_recipe_page [test/integration/recipes_integration_test.rb:91]:
Expected at least 1 element matching "h2", found 0.
Expected 0 to be >= 1.
Refactor: add has_many on Recipe #
You can fix the error first by adding associations on the parent side in Recipe so ingredients and steps are destroyed when you delete a recipe.
Open app/models/recipe.rb and add the following just above validations while leaving the old code intact:
# app/models/recipe.rb
has_many :ingredients, dependent: :destroy
has_many :steps, dependent: :destroy
This is what’s happening in the code above:
has_many :ingredientsandhas_many :stepsdeclare the parent side of the association so@recipe.ingredientsand@recipe.stepswork in views and tests.dependent: :destroytells Rails to delete child rows before deleting the parent, which fixes the foreign key error ontest "destroys a recipe".
Run the integration file again:
bin/rails test test/integration/recipes_integration_test.rb
The error should now go away but the HTTP assertions will still fail because the show template does not list children yet. That is still a red.
F
Failure:
RecipesIntegrationTest#test_shows_ingredients_and_steps_on_the_recipe_page [test/integration/recipes_integration_test.rb:91]:
Expected at least 1 element matching "h2", found 0.
Expected 0 to be >= 1.
Green: add ingredient and step lists to the show template #
Add the following to app/views/recipes/show.html.erb just below the <%= render @recipe %> so the show page of a recipe renders the list of ingredients and steps. When an ingredient has a quantity, show it in parentheses after the name (for example Salt (1 pinch) or Tomatoes (400 g)).
<%# app/views/recipes/show.html.erb %>
<% if @recipe.ingredients.any? %>
<h2>Ingredients</h2>
<ul>
<% @recipe.ingredients.each do |ingredient| %>
<li>
<%= ingredient.name %><% if ingredient.quantity.present? %> (<%= number_with_precision(ingredient.quantity, precision: 2, strip_insignificant_zeros: true) %><% if ingredient.unit.present? %> <%= ingredient.unit %><% end %>)<% end %>
</li>
<% end %>
</ul>
<% end %>
<% if @recipe.steps.any? %>
<h2>Steps</h2>
<ol>
<% @recipe.steps.order(:position).each do |step| %>
<li><%= step.instruction %></li>
<% end %>
</ol>
<% end %>
Use number_with_precision so a decimal column does not print 400.0 when you meant 400 g; decimals keep up to two places (for example 2.5 cups or 400.60 g). Omit the parentheses when quantity is blank.
This is what’s happening in the code above:
- The
if @recipe.ingredients.any?block renders an Ingredients list only when rows exist. - Each
<li>prints the name, then quantity and unit in parentheses whenquantityis present (for example Salt (1 pinch)). number_with_precision(..., precision: 2, strip_insignificant_zeros: true)formats thedecimalcolumn: whole numbers render as 400, not 400.0; values with a fractional part keep up to two decimal places.- The
if @recipe.steps.any?block renders an ordered Steps list usingorder(:position)so step 1 appears before step 2.
Run the integration file again. You want 0 failures and 0 errors.
bin/rails test test/integration/recipes_integration_test.rb
Next, quickly surf the app in the browser before you add tests for nested create:
- Start
bin/devif the server is not running. - Open
http://localhost:3000/recipesand click Fluffy pancakes. - Confirm Salt (1 pinch), Flour (2 cups), and Preheat the pan. appear on the show page.
Create a recipe with an ingredient and a step #
When creating a recipe you want the user to fill the new recipe form with a title, at least one ingredient row, and at least one step row, submit the form, and land on a show page that lists both.
What counts as working? #
| Flow | You might say |
|---|---|
| Create with ingredient and step | Open new, submit title plus one ingredient and one step, redirect to show, both names visible |
Add scenarios to the test file #
# test/integration/recipes_integration_test.rb
# Actor: guest (until Chapter 12)
# Starting point: new recipe form
# Action: open new, submit title, one ingredient row, and one step row
# Expected outcome: one new recipe, ingredient, and step; redirect; both on show
# test "creates a recipe with an ingredient and a step" do
# end
Red: add the integration test #
Add the following test to test/integration/recipes_integration_test.rb to check that Recipe, Ingredient, and Step are all added to the database when creating a recipe:
# test/integration/recipes_integration_test.rb
test "creates a recipe with an ingredient and a step" do
get new_recipe_url
assert_response :success
assert_match "New recipe", response.body
assert_select "h2", "Ingredients"
assert_select "h2", "Steps"
assert_difference ["Recipe.count", "Ingredient.count", "Step.count"], 1 do
post recipes_url, params: {
recipe: {
title: "Tomato soup",
ingredients_attributes: {
"0" => { name: "Tomatoes", quantity: "400", unit: "g" }
},
steps_attributes: {
"0" => { position: "1", instruction: "Simmer until the tomatoes soften" }
}
}
}
end
assert_redirected_to recipe_url(Recipe.last)
follow_redirect!
assert_response :success
assert_match "Tomatoes (400 g)", response.body
assert_match "Simmer until the tomatoes soften", response.body
end
This is what’s happening in the code above:
- Open the new form first, then
postnestedingredients_attributesandsteps_attributeswith a recipe title. - On success, Rails redirects to show; the test checks the formatted ingredient line and step instruction appear in the HTML.
NOTE The post uses indexed key "0" for the first nested row on each side, matching what Rails generates on the form.
Run the integration file. You want a failure on the new form first (missing Ingredients / Steps headings). That is red.
bin/rails test test/integration/recipes_integration_test.rb
F
Failure:
RecipesIntegrationTest#test_creates_a_recipe_with_an_ingredient_and_a_step [test/integration/recipes_integration_test.rb:25]:
Expected at least 1 element matching "h2", found 0.
Expected 0 to be >= 1.
Refactor: wire nested form fields #
Wire the form with nested fields so the Ingredients and Steps headings exist. This will fix the failure you are getting right now for missing heading.
Add accept nested attributes (Rails nested forms guide) in the Recipe model just below the validate :description_cannot_be_whitespace_only at app/models/recipe.rb:
# app/models/recipe.rb
accepts_nested_attributes_for :ingredients, allow_destroy: true
accepts_nested_attributes_for :steps, allow_destroy: true
Next, update the app/views/recipes/_form.html.erb to render nested fields below the existing recipe fields:
<%# app/views/recipes/_form.html.erb %>
<h2>Ingredients</h2>
<%= form.fields_for :ingredients do |ingredient_form| %>
<div>
<%= ingredient_form.label :name %>
<%= ingredient_form.text_field :name %>
</div>
<div>
<%= ingredient_form.label :quantity %>
<%= ingredient_form.number_field :quantity, step: 0.01 %>
</div>
<div>
<%= ingredient_form.label :unit %>
<%= ingredient_form.text_field :unit %>
</div>
<% end %>
<h2>Steps</h2>
<%= form.fields_for :steps do |step_form| %>
<div>
<%= step_form.label :position %>
<%= step_form.number_field :position %>
</div>
<div>
<%= step_form.label :instruction %>
<%= step_form.textarea :instruction %>
</div>
<% end %>
fields_for loops over @recipe.ingredients and @recipe.steps but in new form, those collections start empty so you will build one blank row of each in the controller. If you don’t do this then the form renders headings with no inputs and your integration test’s assert_select can pass while the post still has nothing to bind to.
# app/controllers/recipes_controller.rb
def new
@recipe = Recipe.new
@recipe.ingredients.build
@recipe.steps.build
end
This is what’s happening in the code above:
accepts_nested_attributes_foronRecipeletscreateandupdatesave nestedingredients_attributesandsteps_attributesfrom the form (and_destroywhen you add remove buttons in Chapter 10).fields_for :ingredientsand:stepsin_form.html.erbrender the nested inputs Rails expects in the param hash.@recipe.ingredients.buildand@recipe.steps.buildinnewadd one blank row of each so the form has inputs to fill; withoutbuild, the headings exist but the collections are empty.
Run the integration test again:
bin/rails test test/integration/recipes_integration_test.rb
The h2 assertions on the new form should pass now. But the post part will still fail because nested keys are not permitted yet. That is still red.
F
Failure:
RecipesIntegrationTest#test_creates_a_recipe_with_an_ingredient_and_a_step [test/integration/recipes_integration_test.rb:104]:
`Ingredient.count` didn't change by 1, but by 0.
Expected: 3
Actual: 2
Green: permit nested attributes in strong params #
You need to permit nested keys in recipe_params (see: Rails strong parameters) to fix the new failure. Replace the recipe_params with the following so that controller saves ingredients and steps together with the recipe:
# app/controllers/recipes_controller.rb
def recipe_params
params.expect(
recipe: [
:title,
:description,
:prep_time,
:servings,
{
ingredients_attributes: [%i[id name quantity unit _destroy]],
steps_attributes: [%i[id position instruction _destroy]]
}
]
)
end
This is what’s happening in the code above:
ingredients_attributes: [%i[id name quantity unit _destroy]]permits nested ingredient fields (andid/_destroyfor edit and remove flows later).steps_attributes: [%i[id position instruction _destroy]]does the same for steps.- Until these keys are permitted, Rails drops nested params and
Ingredient.countstays unchanged insideassert_difference.
Run the integration test again:
bin/rails test test/integration/recipes_integration_test.rb
You want 0 failures and 0 errors. Tomatoes (400 g) and Simmer until the tomatoes soften should appear on the show page and the test should be green.
Next, quickly surf nested create in the browser:
- Start
bin/devif the server is not running. - Open New recipe.
- Fill the title, one ingredient row, and one step row, then submit.
- Confirm Tomatoes (400 g) and Simmer until the tomatoes soften on the show page.
Invalid nested children block create #
Model tests already proved which ingredient and step fields are invalid. You will now add integration tests to ensure those invalid validations also break the nested form. You won’t use TDD for this since the nested form is already wired; add the tests and confirm they pass.
What counts as working? #
| Flow | You might say |
|---|---|
| Invalid nested ingredient | Valid title, invalid ingredient row: 422 status, no new rows, ingredient error on the form |
| Invalid nested step | Valid title, invalid step row: 422 status, no new rows, step error on the form |
Add scenarios to the test file #
# test/integration/recipes_integration_test.rb
# Actor: guest
# Starting point: new recipe form with nested ingredient and step fields
# Action: post valid title and invalid nested ingredient data
# Expected outcome: no new recipe or ingredient; 422; ingredient validation error on the form
# test "does not create a recipe with invalid ingredient" do
# end
# Actor: guest
# Starting point: new recipe form with nested ingredient and step fields
# Action: post valid title and invalid nested step data
# Expected outcome: no new recipe or step; 422; step validation error on the form
# test "does not create a recipe with invalid step" do
# end
Add the integration tests #
Update the test/integration/recipes_integration_test.rb with the following tests:
# test/integration/recipes_integration_test.rb
test "does not create a recipe with invalid ingredient" do
assert_no_difference ["Recipe.count", "Ingredient.count"] do
post recipes_url, params: {
recipe: {
title: "Tomato soup",
ingredients_attributes: {
"0" => { name: "Tomatoes", quantity: "-1", unit: "cups" }
}
}
}
end
assert_response :unprocessable_entity
assert_match /prohibited this recipe from being saved/i, response.body
assert_match /ingredients quantity must be greater than 0/i, response.body
assert_select "h1", "New recipe"
end
test "does not create a recipe with invalid step" do
assert_no_difference ["Recipe.count", "Step.count"] do
post recipes_url, params: {
recipe: {
title: "Tomato soup",
steps_attributes: {
"0" => { position: "1", instruction: "" }
}
}
}
end
assert_response :unprocessable_entity
assert_match /prohibited this recipe from being saved/i, response.body
assert_select "li", text: /steps instruction can't be blank/i
assert_select "h1", "New recipe"
end
This is what’s happening in the code above:
- Both tests post a valid recipe
titlewith one invalid nested row (quantity: "-1"or blankinstruction). - Each test expects 422, no new parent or child rows, and the nested error visible on the re-rendered new form (not a redirect).
Rails lists nested errors in the scaffolded view via recipe.errors for e.g. Ingredients quantity must be greater than 0 and Steps instruction can't be blank.
Run:
bin/rails test test/integration/recipes_integration_test.rb
You want 0 failures and 0 errors. If a test fails, common causes are missing accepts_nested_attributes_for, nested keys not permitted in recipe_params, or child validations not on the model yet.
Edit recipe adds an ingredient and a step #
Create proved a new recipe can save nested ingredient and step rows. You will do the same now for edit form as well. The record you edit already contains an ingredient and a step, you will add another ingredient row and another step row to an existing recipe and ensure both are visible on the show page.
What counts as working? #
| Flow | You might say |
|---|---|
| Edit adds ingredient and step | Open edit for pancakes, submit new ingredient and step rows, show lists both |
Add scenarios to the test file #
# test/integration/recipes_integration_test.rb
# Actor: guest
# Starting point: pancakes exists with fixture ingredients and steps
# Action: patch new nested ingredient and step rows
# Expected outcome: redirect to show; new ingredient name and step instruction on the page
# test "adds an ingredient and a step when editing a recipe" do
# end
Add the integration test #
Append the following test to test/integration/recipes_integration_test.rb:
# test/integration/recipes_integration_test.rb
test "adds an ingredient and a step when editing a recipe" do
recipe = recipes(:pancakes)
assert_difference ["Ingredient.count", "Step.count"], 1 do
patch recipe_url(recipe), params: {
recipe: {
title: recipe.title,
ingredients_attributes: {
"0" => { name: "Pepper", quantity: "1", unit: "pinch" }
},
steps_attributes: {
"0" => { position: "2", instruction: "Flip and serve." }
}
}
}
end
assert_redirected_to recipe_url(recipe)
follow_redirect!
assert_response :success
assert_match "Pepper (1 pinch)", response.body
assert_match "Flip and serve.", response.body
end
This is what’s happening in the code above:
patchsends new nested rows without anidkey, so Rails creates new child records.updates a recipealready proved the edit form loads, so this test skipsget edit_recipe_url.- After redirect, the show page should list the new pepper line and flip-and-serve step.
Run:
bin/rails test test/integration/recipes_integration_test.rb
You want 0 failures and 0 errors.
Browser smoke: nested create in the browser #
You have already proved with integration tests that ingredient and step nested records over HTTP works for the create flow. It’s time to add an optional system test to confirm both nested fields also work in a real browser: ingredient fields (Name, Quantity, Unit) and step fields (Position, Instruction).
Before you write the system test, open New recipe in the browser, fill one ingredient row and one step row, and note the exact labels Capybara will need to find.
What counts as working? #
| Flow | You might say |
|---|---|
| Create with ingredient and step in browser | Fill title, one ingredient row, and one step row; submit; both appear on show |
Add scenarios to the test file #
In test/system/recipes_test.rb, update the scenario comment on creates a recipe (from Chapter 7):
# test/system/recipes_test.rb
# Actor: guest (until Chapter 12)
# Starting point: new recipe form open in browser
# Action: fill title, one ingredient row, and one step row; submit
# Expected outcome: recipe title, formatted ingredient line, and step instruction on show
# test "creates a recipe" do
# end
Update the test #
For system tests we will limit one test block per feature so we can smoke check everything in one run unlike what we did for integration tests by adding multiple tests for the same feature to test different flow (edge cases). This is also to ensure system tests are small in size and faster to run.
Replace the body of test "creates a recipe" with the following.
# test/system/recipes_test.rb
test "creates a recipe" do
visit new_recipe_url
fill_in "Title", with: "Tomato soup"
fill_in "Name", with: "Tomatoes"
fill_in "Quantity", with: "400"
fill_in "Unit", with: "g"
fill_in "Position", with: "1"
fill_in "Instruction", with: "Simmer until the tomatoes soften"
click_on "Create Recipe"
assert_text "Tomato soup"
assert_text "Tomatoes (400 g)"
assert_text "Simmer until the tomatoes soften"
end
This is what’s happening in the code above:
visit new_recipe_urlopens the new recipe page in a real browser (Capybara).- Fill the title, one ingredient row, and one step row, then submit the way a reader would.
- After redirect, the show page should display the title, formatted ingredient line, and step instruction.
Run:
bin/rails test test/system/recipes_test.rb
You want 0 failures and 0 errors.
Nested ingredients and steps now work in model tests, integration tests, and an optional browser smoke on the same create path from Chapter 7.
Nested forms with tests at every layer is a real milestone.
This is where nested attributes click: fixtures, show page, create, invalid rows, and edit all have a test home. If that habit is sticking, fund the next chapter and keep the guide free.
One-time support via Stripe. No account required.
Full recipes_integration_test.rb #
You have added several new tests to the integration file, so test/integration/recipes_integration_test.rb is much longer than when you started this chapter. If a test fails and you are not sure what you missed, diff your file against this assembled version. Test order can differ; assertions should match.
# test/integration/recipes_integration_test.rb
require "test_helper"
class RecipesIntegrationTest < ActionDispatch::IntegrationTest
test "visits the list" do
get recipes_url
assert_response :success
assert_match "Recipes", response.body
assert_match recipes(:pancakes).title, response.body
assert_select "#recipes div[id^='recipe_']", count: Recipe.count
end
test "shows a recipe" do
get recipe_url(recipes(:pancakes))
assert_response :success
assert_match recipes(:pancakes).title, response.body
end
test "shows ingredients and steps on the recipe page" do
get recipe_url(recipes(:pancakes))
assert_response :success
assert_match recipes(:pancakes).title, response.body
assert_select "h2", "Ingredients"
assert_match "Salt (1 pinch)", response.body
assert_match "Flour (2 cups)", response.body
assert_select "h2", "Steps"
assert_match steps(:preheat).instruction, response.body
end
test "creates a recipe" do
get new_recipe_url
assert_response :success
assert_match "New recipe", response.body
assert_difference("Recipe.count", 1) do
post recipes_url, params: { recipe: { title: "Lentil soup" } }
end
assert_redirected_to recipe_url(Recipe.last)
follow_redirect!
assert_response :success
assert_match "Lentil soup", response.body
end
test "creates a recipe with an ingredient and a step" do
get new_recipe_url
assert_response :success
assert_match "New recipe", response.body
assert_select "h2", "Ingredients"
assert_select "h2", "Steps"
assert_difference ["Recipe.count", "Ingredient.count", "Step.count"], 1 do
post recipes_url, params: {
recipe: {
title: "Tomato soup",
ingredients_attributes: {
"0" => { name: "Tomatoes", quantity: "400", unit: "g" }
},
steps_attributes: {
"0" => { position: "1", instruction: "Simmer until the tomatoes soften" }
}
}
}
end
assert_redirected_to recipe_url(Recipe.last)
follow_redirect!
assert_response :success
assert_match "Tomatoes (400 g)", response.body
assert_match "Simmer until the tomatoes soften", response.body
end
test "does not create a recipe with invalid data" do
assert_no_difference("Recipe.count") do
post recipes_url, params: { recipe: { title: "" } }
end
assert_response :unprocessable_entity
assert_match /prohibited this recipe from being saved/i, response.body
assert_select "h1", "New recipe"
end
test "does not create a recipe with invalid ingredient" do
assert_no_difference ["Recipe.count", "Ingredient.count"] do
post recipes_url, params: {
recipe: {
title: "Tomato soup",
ingredients_attributes: {
"0" => { name: "Tomatoes", quantity: "-1", unit: "cups" }
}
}
}
end
assert_response :unprocessable_entity
assert_match /prohibited this recipe from being saved/i, response.body
assert_match /ingredients quantity must be greater than 0/i, response.body
assert_select "h1", "New recipe"
end
test "does not create a recipe with invalid step" do
assert_no_difference ["Recipe.count", "Step.count"] do
post recipes_url, params: {
recipe: {
title: "Tomato soup",
steps_attributes: {
"0" => { position: "1", instruction: "" }
}
}
}
end
assert_response :unprocessable_entity
assert_match /prohibited this recipe from being saved/i, response.body
assert_select "li", text: /steps instruction can't be blank/i
assert_select "h1", "New recipe"
end
test "updates a recipe" do
recipe = recipes(:pancakes)
get edit_recipe_url(recipe)
assert_response :success
assert_select "h1", "Editing recipe"
patch recipe_url(recipe), params: { recipe: { title: "Extra fluffy pancakes" } }
assert_redirected_to recipe_url(recipe)
follow_redirect!
assert_response :success
assert_match "Extra fluffy pancakes", response.body
end
test "adds an ingredient and a step when editing a recipe" do
recipe = recipes(:pancakes)
assert_difference ["Ingredient.count", "Step.count"], 1 do
patch recipe_url(recipe), params: {
recipe: {
title: recipe.title,
ingredients_attributes: {
"0" => { name: "Pepper", quantity: "1", unit: "pinch" }
},
steps_attributes: {
"0" => { position: "2", instruction: "Flip and serve." }
}
}
}
end
assert_redirected_to recipe_url(recipe)
follow_redirect!
assert_response :success
assert_match "Pepper (1 pinch)", response.body
assert_match "Flip and serve.", response.body
end
test "destroys a recipe" do
recipe = recipes(:lentil_soup)
assert_difference("Recipe.count", -1) do
delete recipe_url(recipe)
end
assert_redirected_to recipes_url
follow_redirect!
assert_response :success
assert_select "#recipes div[id^='recipe_']", count: Recipe.count
end
test "filters the list to quick recipes" do
get recipes_url
assert_response :success
assert_select "form.recipe-filters"
assert_select "select#quick"
get recipes_url, params: { quick: "1" }
assert_response :success
assert_match recipes(:pancakes).title, response.body
assert_no_match recipes(:lentil_soup).title, response.body
end
end
Commit your work #
Save a checkpoint once the full suite is green.
Run the full suite:
bin/rails test:all
You want 0 failures and 0 errors. Then:
git add .
git commit -m "Add ingredients and steps nested on recipes with tests"
Small commits make it easier to roll back, bisect a regression, or pick up on another machine.
What is next #
Chapter 10 wires dynamic nested fields (add and remove rows with Stimulus) and the integration and system tests that prove multi-row create and remove on edit. Chapter 11 covers Turbo Streams and Frames next.
When you wire recipes to owners in Chapter 13, you will use the same association pattern in YAML (user: alice on a recipe row). The ActiveRecord::FixtureSet docs list other association styles if your app outgrows simple belongs_to labels.
Continue to Testing Dynamic Forms.
Keep Minitest Rails independent
Minitest Rails is an independent educational guide for Rails developers learning automated testing.
Reader support funds new chapters, Rails version updates, and more real-world examples.
Disclaimer: This guide is based on hands-on Rails and testing experience and was proofread by AI. I stand by the advice and patterns here.